Skip to main content

bamboo_domain/session/
provider_transcript.rs

1//! Durable, provider-native transcript items used by progressive tool discovery.
2//!
3//! This lane is deliberately separate from [`Session::messages`]. The latter is
4//! the provider-neutral, user-visible transcript; this module preserves the
5//! small set of provider-owned items whose exact position carries tool-loading
6//! state. Raw JSON is admitted only through the validators below, so persisted
7//! data cannot become an unrestricted request-injection channel.
8
9use std::collections::{HashMap, HashSet};
10use std::fmt;
11
12use serde::de::Error as _;
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17
18use super::Session;
19
20pub const PROVIDER_TRANSCRIPT_SCHEMA_VERSION: u32 = 1;
21const ITEM_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-item/v1\0";
22const GROUP_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-group/v1\0";
23const ANCHOR_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-anchor/v1\0";
24const TRANSCRIPT_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-state/v1\0";
25const PROVIDER_BOUNDARY_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-boundary/v1\0";
26
27/// Provider identity boundary for native transcript replay.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ProviderFamily {
31    OpenAi,
32    Anthropic,
33    Copilot,
34}
35
36impl ProviderFamily {
37    /// Resolve Bamboo's underlying provider type into a native replay family.
38    /// Unknown/compatibility providers intentionally return `None`.
39    pub fn from_provider_type(provider_type: Option<&str>) -> Option<Self> {
40        match provider_type.map(str::trim) {
41            Some("openai") => Some(Self::OpenAi),
42            Some("anthropic") => Some(Self::Anthropic),
43            Some("copilot") => Some(Self::Copilot),
44            _ => None,
45        }
46    }
47}
48
49/// Concrete wire protocol whose items may be replayed.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ProviderProtocol {
53    OpenAiResponsesV1,
54    AnthropicMessages2023_06_01,
55}
56
57impl ProviderProtocol {
58    pub fn supports_family(self, family: ProviderFamily) -> bool {
59        match self {
60            Self::OpenAiResponsesV1 => {
61                matches!(family, ProviderFamily::OpenAi | ProviderFamily::Copilot)
62            }
63            Self::AnthropicMessages2023_06_01 => family == ProviderFamily::Anthropic,
64        }
65    }
66}
67
68/// Derive a secret-free provider routing boundary for native replay. The raw
69/// provider instance id/type is never persisted or emitted by diagnostics; the
70/// digest is sufficient to distinguish same-family instances across restart
71/// and append-safe merge.
72pub fn provider_transcript_boundary_sha256(
73    provider_name: Option<&str>,
74    provider_type: Option<&str>,
75) -> Option<String> {
76    let provider_name = provider_name
77        .map(str::trim)
78        .filter(|value| !value.is_empty());
79    let provider_type = provider_type
80        .map(str::trim)
81        .filter(|value| !value.is_empty());
82    if provider_name.is_none() && provider_type.is_none() {
83        return None;
84    }
85    Some(hash_json(
86        PROVIDER_BOUNDARY_HASH_DOMAIN,
87        &serde_json::json!({
88            "provider_name": provider_name,
89            "provider_type": provider_type.map(str::to_ascii_lowercase),
90        }),
91    ))
92}
93
94fn unbound_provider_boundary_sha256() -> String {
95    hash_bytes(PROVIDER_BOUNDARY_HASH_DOMAIN, b"unbound")
96}
97
98fn is_sha256_hex(value: &str) -> bool {
99    value.len() == 64
100        && value
101            .bytes()
102            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum ProviderTranscriptOrigin {
108    Provider,
109    HostToolSearch,
110    DeveloperContext,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ProviderTranscriptAuthor {
116    Model,
117    Host,
118    ToolResult,
119}
120
121/// The bounded provider item variants Bamboo is willing to replay.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum ProviderTranscriptItemKind {
125    OpenAiMessage,
126    OpenAiReasoning,
127    OpenAiFunctionCall,
128    OpenAiToolSearchCall,
129    OpenAiToolSearchOutput,
130    OpenAiAdditionalTools,
131    AnthropicText,
132    AnthropicThinking,
133    AnthropicRedactedThinking,
134    AnthropicServerToolUse,
135    AnthropicToolSearchToolResult,
136    AnthropicToolUse,
137    AnthropicToolResult,
138}
139
140impl ProviderTranscriptItemKind {
141    pub fn is_discovery(self) -> bool {
142        matches!(
143            self,
144            Self::OpenAiToolSearchCall
145                | Self::OpenAiToolSearchOutput
146                | Self::OpenAiAdditionalTools
147                | Self::AnthropicServerToolUse
148                | Self::AnthropicToolSearchToolResult
149                | Self::AnthropicToolResult
150        )
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ProviderTranscriptResetReason {
157    ProviderSwitch,
158    Compression,
159    Rollback,
160    HardTruncation,
161    CacheScopeChanged,
162    RetentionLimit,
163    ExplicitHistoryRewrite,
164}
165
166#[derive(Debug, Error, Clone, PartialEq, Eq)]
167pub enum ProviderTranscriptError {
168    #[error("provider transcript payload must be a JSON object")]
169    PayloadNotObject,
170    #[error("unsupported provider transcript item type")]
171    UnsupportedItemType,
172    #[error("invalid provider transcript item: {0}")]
173    InvalidItem(&'static str),
174    #[error("provider family and protocol do not match")]
175    FamilyProtocolMismatch,
176    #[error("provider transcript group must contain at least one item")]
177    EmptyGroup,
178    #[error("provider transcript group has no discovery item")]
179    MissingDiscoveryItem,
180    #[error("provider transcript group mixes provider families or protocols")]
181    MixedProviderGroup,
182    #[error("provider transcript group anchor is empty or missing")]
183    InvalidAnchor,
184    #[error("provider transcript group order is invalid")]
185    InvalidGroupOrder,
186    #[error("provider transcript state contains duplicate group ids")]
187    DuplicateGroupId,
188    #[error("provider transcript state contains an invalid current-epoch sequence")]
189    InvalidStateSequence,
190    #[error("provider transcript state contains a group from a future epoch")]
191    FutureGroupEpoch,
192    #[error("provider transcript state current epoch does not match its active family")]
193    CurrentEpochFamilyMismatch,
194    #[error("provider transcript state current epoch does not match its active protocol")]
195    CurrentEpochProtocolMismatch,
196    #[error("provider transcript state has an invalid provider boundary")]
197    InvalidProviderBoundary,
198    #[error("provider transcript item belongs to a non-active provider route")]
199    InactiveProviderRoute,
200}
201
202/// One exact provider-owned item. `payload` stays private so callers cannot
203/// bypass validation after construction.
204#[derive(Clone, PartialEq, Serialize)]
205pub struct ProviderTranscriptItem {
206    family: ProviderFamily,
207    protocol: ProviderProtocol,
208    origin: ProviderTranscriptOrigin,
209    author: ProviderTranscriptAuthor,
210    kind: ProviderTranscriptItemKind,
211    id: String,
212    payload: Value,
213}
214
215#[derive(Deserialize)]
216struct ProviderTranscriptItemWire {
217    family: ProviderFamily,
218    protocol: ProviderProtocol,
219    origin: ProviderTranscriptOrigin,
220    author: ProviderTranscriptAuthor,
221    kind: ProviderTranscriptItemKind,
222    id: String,
223    payload: Value,
224}
225
226impl<'de> Deserialize<'de> for ProviderTranscriptItem {
227    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228    where
229        D: Deserializer<'de>,
230    {
231        let wire = ProviderTranscriptItemWire::deserialize(deserializer)?;
232        let item = Self::try_from_payload(
233            wire.family,
234            wire.protocol,
235            wire.origin,
236            wire.author,
237            wire.payload,
238        )
239        .map_err(D::Error::custom)?;
240        if item.kind != wire.kind || item.id != wire.id {
241            return Err(D::Error::custom(
242                "provider transcript kind/id does not match its validated payload",
243            ));
244        }
245        Ok(item)
246    }
247}
248
249impl ProviderTranscriptItem {
250    pub fn try_from_payload(
251        family: ProviderFamily,
252        protocol: ProviderProtocol,
253        origin: ProviderTranscriptOrigin,
254        author: ProviderTranscriptAuthor,
255        payload: Value,
256    ) -> Result<Self, ProviderTranscriptError> {
257        if !protocol.supports_family(family) {
258            return Err(ProviderTranscriptError::FamilyProtocolMismatch);
259        }
260        let kind = infer_and_validate_item(protocol, origin, author, &payload)?;
261        let id = stable_item_id(family, protocol, origin, author, kind, &payload)?;
262        Ok(Self {
263            family,
264            protocol,
265            origin,
266            author,
267            kind,
268            id,
269            payload,
270        })
271    }
272
273    pub fn family(&self) -> ProviderFamily {
274        self.family
275    }
276
277    pub fn protocol(&self) -> ProviderProtocol {
278        self.protocol
279    }
280
281    pub fn origin(&self) -> ProviderTranscriptOrigin {
282        self.origin
283    }
284
285    pub fn author(&self) -> ProviderTranscriptAuthor {
286        self.author
287    }
288
289    pub fn kind(&self) -> ProviderTranscriptItemKind {
290        self.kind
291    }
292
293    pub fn id(&self) -> &str {
294        &self.id
295    }
296
297    pub fn payload(&self) -> &Value {
298        &self.payload
299    }
300
301    pub fn payload_sha256(&self) -> String {
302        hash_json(ITEM_HASH_DOMAIN, &self.payload)
303    }
304}
305
306/// Debug output is deliberately payload-free. Provider items may include tool
307/// schemas, arguments, paths, or opaque provider fields.
308impl fmt::Debug for ProviderTranscriptItem {
309    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310        formatter
311            .debug_struct("ProviderTranscriptItem")
312            .field("family", &self.family)
313            .field("protocol", &self.protocol)
314            .field("origin", &self.origin)
315            .field("author", &self.author)
316            .field("kind", &self.kind)
317            .field("id", &self.id)
318            .field("payload_sha256", &self.payload_sha256())
319            .finish()
320    }
321}
322
323/// Items that must survive or be removed together, anchored at one ordinary
324/// transcript message. The anchor gives later provider adapters an exact
325/// chronological insertion/replacement point without putting raw items inside
326/// [`super::Message`].
327#[derive(Clone, PartialEq, Serialize)]
328pub struct ProviderTranscriptGroup {
329    id: String,
330    epoch: u64,
331    sequence: u64,
332    anchor_message_id: String,
333    family: ProviderFamily,
334    protocol: ProviderProtocol,
335    provider_boundary_sha256: String,
336    items: Vec<ProviderTranscriptItem>,
337}
338
339#[derive(Deserialize)]
340struct ProviderTranscriptGroupWire {
341    id: String,
342    epoch: u64,
343    sequence: u64,
344    anchor_message_id: String,
345    family: ProviderFamily,
346    protocol: ProviderProtocol,
347    provider_boundary_sha256: String,
348    items: Vec<ProviderTranscriptItem>,
349}
350
351impl<'de> Deserialize<'de> for ProviderTranscriptGroup {
352    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
353    where
354        D: Deserializer<'de>,
355    {
356        let wire = ProviderTranscriptGroupWire::deserialize(deserializer)?;
357        let group = Self::new(
358            wire.epoch,
359            wire.sequence,
360            wire.anchor_message_id,
361            Some(wire.id.as_str()),
362            wire.provider_boundary_sha256,
363            wire.items,
364        )
365        .map_err(D::Error::custom)?;
366        if group.id != wire.id || group.family != wire.family || group.protocol != wire.protocol {
367            return Err(D::Error::custom(
368                "provider transcript group identity does not match its validated items",
369            ));
370        }
371        Ok(group)
372    }
373}
374
375impl ProviderTranscriptGroup {
376    /// Validate an atomic provider-native batch before any item is exposed to
377    /// the engine stream. Provider adapters use this same admission path as
378    /// durable group construction so malformed ordering cannot fail later,
379    /// after the normalized assistant response has already been committed.
380    pub fn validate_items(items: &[ProviderTranscriptItem]) -> Result<(), ProviderTranscriptError> {
381        validated_group_identity(items).map(|_| ())
382    }
383
384    fn new(
385        epoch: u64,
386        sequence: u64,
387        anchor_message_id: String,
388        id_hint: Option<&str>,
389        provider_boundary_sha256: String,
390        items: Vec<ProviderTranscriptItem>,
391    ) -> Result<Self, ProviderTranscriptError> {
392        if anchor_message_id.trim().is_empty() {
393            return Err(ProviderTranscriptError::InvalidAnchor);
394        }
395        let Some(first) = items.first() else {
396            return Err(ProviderTranscriptError::EmptyGroup);
397        };
398        let family = first.family;
399        let protocol = first.protocol;
400        if !is_sha256_hex(&provider_boundary_sha256) {
401            return Err(ProviderTranscriptError::InvalidProviderBoundary);
402        }
403        Self::validate_items(&items)?;
404
405        let id = stable_group_id(
406            epoch,
407            family,
408            protocol,
409            &provider_boundary_sha256,
410            &anchor_message_id,
411            id_hint,
412            &items,
413        );
414        Ok(Self {
415            id,
416            epoch,
417            sequence,
418            anchor_message_id,
419            family,
420            protocol,
421            provider_boundary_sha256,
422            items,
423        })
424    }
425
426    pub fn id(&self) -> &str {
427        &self.id
428    }
429
430    pub fn epoch(&self) -> u64 {
431        self.epoch
432    }
433
434    pub fn sequence(&self) -> u64 {
435        self.sequence
436    }
437
438    pub fn anchor_message_id(&self) -> &str {
439        &self.anchor_message_id
440    }
441
442    pub fn family(&self) -> ProviderFamily {
443        self.family
444    }
445
446    pub fn protocol(&self) -> ProviderProtocol {
447        self.protocol
448    }
449
450    pub fn provider_boundary_sha256(&self) -> &str {
451        &self.provider_boundary_sha256
452    }
453
454    pub fn items(&self) -> &[ProviderTranscriptItem] {
455        &self.items
456    }
457}
458
459impl fmt::Debug for ProviderTranscriptGroup {
460    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
461        formatter
462            .debug_struct("ProviderTranscriptGroup")
463            .field("id", &self.id)
464            .field("epoch", &self.epoch)
465            .field("sequence", &self.sequence)
466            .field(
467                "anchor_message_sha256",
468                &hash_bytes(ANCHOR_HASH_DOMAIN, self.anchor_message_id.as_bytes()),
469            )
470            .field("family", &self.family)
471            .field("protocol", &self.protocol)
472            .field("provider_boundary_sha256", &self.provider_boundary_sha256)
473            .field(
474                "item_kinds",
475                &self.items.iter().map(|item| item.kind).collect::<Vec<_>>(),
476            )
477            .finish()
478    }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
482pub struct ProviderTranscriptDiagnostics {
483    pub group_count: usize,
484    pub item_count: usize,
485    pub serialized_bytes: usize,
486    pub sha256: String,
487}
488
489/// Durable native transcript state. Only groups in the active family and
490/// current epoch are replayable; earlier epochs remain persisted for audit and
491/// normalized fallback but can never cross a provider switch.
492#[derive(Clone, PartialEq, Serialize)]
493pub struct ProviderTranscriptState {
494    schema_version: u32,
495    state_revision: u64,
496    epoch: u64,
497    next_sequence: u64,
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    active_family: Option<ProviderFamily>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    active_protocol: Option<ProviderProtocol>,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    active_provider_boundary_sha256: Option<String>,
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    groups: Vec<ProviderTranscriptGroup>,
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    last_reset_reason: Option<ProviderTranscriptResetReason>,
508}
509
510#[derive(Deserialize)]
511struct ProviderTranscriptStateWire {
512    #[serde(default)]
513    schema_version: Option<u32>,
514    #[serde(default)]
515    state_revision: u64,
516    #[serde(default)]
517    epoch: u64,
518    #[serde(default)]
519    next_sequence: u64,
520    #[serde(default)]
521    active_family: Option<ProviderFamily>,
522    #[serde(default)]
523    active_protocol: Option<ProviderProtocol>,
524    #[serde(default)]
525    active_provider_boundary_sha256: Option<String>,
526    #[serde(default)]
527    groups: Vec<ProviderTranscriptGroup>,
528    #[serde(default)]
529    last_reset_reason: Option<ProviderTranscriptResetReason>,
530}
531
532impl<'de> Deserialize<'de> for ProviderTranscriptState {
533    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
534    where
535        D: Deserializer<'de>,
536    {
537        let wire = ProviderTranscriptStateWire::deserialize(deserializer)?;
538        let wire_is_empty = wire.state_revision == 0
539            && wire.epoch == 0
540            && wire.next_sequence == 0
541            && wire.active_family.is_none()
542            && wire.active_protocol.is_none()
543            && wire.active_provider_boundary_sha256.is_none()
544            && wire.groups.is_empty()
545            && wire.last_reset_reason.is_none();
546        let schema_version = match wire.schema_version {
547            Some(PROVIDER_TRANSCRIPT_SCHEMA_VERSION) => PROVIDER_TRANSCRIPT_SCHEMA_VERSION,
548            Some(_) => {
549                return Err(D::Error::custom(
550                    "unsupported provider transcript schema version",
551                ))
552            }
553            None if wire_is_empty => PROVIDER_TRANSCRIPT_SCHEMA_VERSION,
554            None => {
555                return Err(D::Error::custom(
556                    "missing provider transcript schema version",
557                ))
558            }
559        };
560        let active_route_fields = [
561            wire.active_family.is_some(),
562            wire.active_protocol.is_some(),
563            wire.active_provider_boundary_sha256.is_some(),
564        ];
565        if active_route_fields.iter().any(|present| *present)
566            && !active_route_fields.iter().all(|present| *present)
567        {
568            return Err(D::Error::custom(
569                ProviderTranscriptError::InvalidProviderBoundary,
570            ));
571        }
572        if wire
573            .active_provider_boundary_sha256
574            .as_deref()
575            .is_some_and(|boundary| !is_sha256_hex(boundary))
576        {
577            return Err(D::Error::custom(
578                ProviderTranscriptError::InvalidProviderBoundary,
579            ));
580        }
581        if let (Some(family), Some(protocol)) = (wire.active_family, wire.active_protocol) {
582            if !protocol.supports_family(family) {
583                return Err(D::Error::custom(
584                    ProviderTranscriptError::FamilyProtocolMismatch,
585                ));
586            }
587        }
588        let mut ids = HashSet::new();
589        if wire
590            .groups
591            .iter()
592            .any(|group| !ids.insert(group.id.clone()))
593        {
594            return Err(D::Error::custom(ProviderTranscriptError::DuplicateGroupId));
595        }
596        if wire.groups.iter().any(|group| group.epoch > wire.epoch) {
597            return Err(D::Error::custom(ProviderTranscriptError::FutureGroupEpoch));
598        }
599        let mut current_sequences = HashSet::new();
600        let mut current_max_sequence = None;
601        for group in wire.groups.iter().filter(|group| group.epoch == wire.epoch) {
602            if wire.active_family != Some(group.family) {
603                return Err(D::Error::custom(
604                    ProviderTranscriptError::CurrentEpochFamilyMismatch,
605                ));
606            }
607            if wire.active_protocol != Some(group.protocol) {
608                return Err(D::Error::custom(
609                    ProviderTranscriptError::CurrentEpochProtocolMismatch,
610                ));
611            }
612            if wire.active_provider_boundary_sha256.as_deref()
613                != Some(group.provider_boundary_sha256())
614            {
615                return Err(D::Error::custom(
616                    ProviderTranscriptError::InvalidProviderBoundary,
617                ));
618            }
619            if !current_sequences.insert(group.sequence) {
620                return Err(D::Error::custom(
621                    ProviderTranscriptError::InvalidStateSequence,
622                ));
623            }
624            current_max_sequence = Some(
625                current_max_sequence
626                    .map_or(group.sequence, |current: u64| current.max(group.sequence)),
627            );
628        }
629        if current_max_sequence.is_some_and(|sequence| sequence >= wire.next_sequence) {
630            return Err(D::Error::custom(
631                ProviderTranscriptError::InvalidStateSequence,
632            ));
633        }
634        Ok(Self {
635            schema_version,
636            state_revision: wire.state_revision,
637            epoch: wire.epoch,
638            next_sequence: wire.next_sequence,
639            active_family: wire.active_family,
640            active_protocol: wire.active_protocol,
641            active_provider_boundary_sha256: wire.active_provider_boundary_sha256,
642            groups: wire.groups,
643            last_reset_reason: wire.last_reset_reason,
644        })
645    }
646}
647
648impl Default for ProviderTranscriptState {
649    fn default() -> Self {
650        Self {
651            schema_version: PROVIDER_TRANSCRIPT_SCHEMA_VERSION,
652            state_revision: 0,
653            epoch: 0,
654            next_sequence: 0,
655            active_family: None,
656            active_protocol: None,
657            active_provider_boundary_sha256: None,
658            groups: Vec::new(),
659            last_reset_reason: None,
660        }
661    }
662}
663
664impl fmt::Debug for ProviderTranscriptState {
665    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
666        formatter
667            .debug_struct("ProviderTranscriptState")
668            .field("schema_version", &self.schema_version)
669            .field("state_revision", &self.state_revision)
670            .field("epoch", &self.epoch)
671            .field("next_sequence", &self.next_sequence)
672            .field("active_family", &self.active_family)
673            .field("active_protocol", &self.active_protocol)
674            .field(
675                "active_provider_boundary_sha256",
676                &self.active_provider_boundary_sha256,
677            )
678            .field("groups", &self.groups)
679            .field("last_reset_reason", &self.last_reset_reason)
680            .finish()
681    }
682}
683
684impl ProviderTranscriptState {
685    pub fn is_empty(&self) -> bool {
686        self.state_revision == 0
687            && self.epoch == 0
688            && self.next_sequence == 0
689            && self.active_family.is_none()
690            && self.active_protocol.is_none()
691            && self.active_provider_boundary_sha256.is_none()
692            && self.groups.is_empty()
693            && self.last_reset_reason.is_none()
694    }
695
696    pub fn state_revision(&self) -> u64 {
697        self.state_revision
698    }
699
700    pub fn epoch(&self) -> u64 {
701        self.epoch
702    }
703
704    pub fn active_family(&self) -> Option<ProviderFamily> {
705        self.active_family
706    }
707
708    pub fn active_protocol(&self) -> Option<ProviderProtocol> {
709        self.active_protocol
710    }
711
712    pub fn active_provider_boundary_sha256(&self) -> Option<&str> {
713        self.active_provider_boundary_sha256.as_deref()
714    }
715
716    pub fn last_reset_reason(&self) -> Option<ProviderTranscriptResetReason> {
717        self.last_reset_reason
718    }
719
720    pub fn groups(&self) -> &[ProviderTranscriptGroup] {
721        &self.groups
722    }
723
724    /// Select the exact provider route for the next request. A family,
725    /// protocol, or same-family instance switch starts a new replay epoch,
726    /// making every older provider-minted item unreachable.
727    pub fn activate_route(
728        &mut self,
729        family: ProviderFamily,
730        protocol: ProviderProtocol,
731        provider_boundary_sha256: &str,
732    ) -> Result<bool, ProviderTranscriptError> {
733        if !protocol.supports_family(family) {
734            return Err(ProviderTranscriptError::FamilyProtocolMismatch);
735        }
736        if !is_sha256_hex(provider_boundary_sha256) {
737            return Err(ProviderTranscriptError::InvalidProviderBoundary);
738        }
739        if self.active_family == Some(family)
740            && self.active_protocol == Some(protocol)
741            && self.active_provider_boundary_sha256.as_deref() == Some(provider_boundary_sha256)
742        {
743            return Ok(false);
744        }
745        let is_switch = self.active_family.is_some()
746            || self.active_protocol.is_some()
747            || self.active_provider_boundary_sha256.is_some()
748            || !self.groups.is_empty();
749        if is_switch {
750            self.epoch = self.epoch.saturating_add(1);
751            self.next_sequence = 0;
752            self.last_reset_reason = Some(ProviderTranscriptResetReason::ProviderSwitch);
753        }
754        self.active_family = Some(family);
755        self.active_protocol = Some(protocol);
756        self.active_provider_boundary_sha256 = Some(provider_boundary_sha256.to_string());
757        self.state_revision = self.state_revision.saturating_add(1);
758        Ok(true)
759    }
760
761    /// Leave native replay when a session moves to a provider family Bamboo
762    /// cannot identify. Older items remain auditable but the new epoch has no
763    /// family capable of replaying them.
764    pub fn deactivate_route(&mut self) -> bool {
765        if self.active_family.is_none()
766            && self.active_protocol.is_none()
767            && self.active_provider_boundary_sha256.is_none()
768        {
769            return false;
770        }
771        self.epoch = self.epoch.saturating_add(1);
772        self.next_sequence = 0;
773        self.active_family = None;
774        self.active_protocol = None;
775        self.active_provider_boundary_sha256 = None;
776        self.last_reset_reason = Some(ProviderTranscriptResetReason::ProviderSwitch);
777        self.state_revision = self.state_revision.saturating_add(1);
778        true
779    }
780
781    pub fn invalidate(&mut self, reason: ProviderTranscriptResetReason) {
782        self.invalidate_repeated(reason, 1);
783    }
784
785    /// Coalesce missed host selections without replaying an unbounded loop.
786    /// This is exactly repeated invalidation of the existing native epoch.
787    pub(super) fn invalidate_repeated(
788        &mut self,
789        reason: ProviderTranscriptResetReason,
790        count: u64,
791    ) {
792        if count == 0 || self.is_empty() {
793            return;
794        }
795        self.epoch = self.epoch.saturating_add(count);
796        self.next_sequence = 0;
797        self.last_reset_reason = Some(reason);
798        self.state_revision = self.state_revision.saturating_add(count);
799    }
800
801    pub fn append_group(
802        &mut self,
803        anchor_message_id: impl Into<String>,
804        id_hint: Option<&str>,
805        items: Vec<ProviderTranscriptItem>,
806    ) -> Result<String, ProviderTranscriptError> {
807        let first = items.first().ok_or(ProviderTranscriptError::EmptyGroup)?;
808        let family = first.family;
809        let protocol = first.protocol;
810        let (provider_boundary_sha256, activates_unbound_route) = match (
811            self.active_family,
812            self.active_protocol,
813            self.active_provider_boundary_sha256.as_deref(),
814        ) {
815            (Some(active_family), Some(active_protocol), Some(boundary))
816                if active_family == family && active_protocol == protocol =>
817            {
818                (boundary.to_string(), false)
819            }
820            (Some(_), Some(_), Some(_)) => {
821                return Err(ProviderTranscriptError::InactiveProviderRoute)
822            }
823            (None, None, None) => (unbound_provider_boundary_sha256(), true),
824            _ => return Err(ProviderTranscriptError::InactiveProviderRoute),
825        };
826        // Validate the complete atomic group before mutating provider/epoch
827        // state. A malformed provider frame must fail closed without leaving a
828        // half-activated transcript lane behind.
829        let group = ProviderTranscriptGroup::new(
830            self.epoch,
831            self.next_sequence,
832            anchor_message_id.into(),
833            id_hint,
834            provider_boundary_sha256.clone(),
835            items,
836        )?;
837        if let Some(existing) = self.groups.iter().find(|current| current.id == group.id) {
838            if existing.epoch == group.epoch
839                && existing.anchor_message_id == group.anchor_message_id
840                && existing.family == group.family
841                && existing.protocol == group.protocol
842                && existing.provider_boundary_sha256 == group.provider_boundary_sha256
843                && existing.items == group.items
844            {
845                return Ok(group.id);
846            }
847            return Err(ProviderTranscriptError::DuplicateGroupId);
848        }
849        if activates_unbound_route {
850            self.active_family = Some(family);
851            self.active_protocol = Some(protocol);
852            self.active_provider_boundary_sha256 = Some(provider_boundary_sha256);
853            self.state_revision = self.state_revision.saturating_add(1);
854        }
855        let id = group.id.clone();
856        self.groups.push(group);
857        self.next_sequence = self.next_sequence.saturating_add(1);
858        self.state_revision = self.state_revision.saturating_add(1);
859        Ok(id)
860    }
861
862    /// Drop whole groups whose ordinary-message anchor no longer exists. No
863    /// item within a group is ever retained independently.
864    pub fn prune_dangling_groups(&mut self, live_message_ids: &HashSet<String>) -> usize {
865        let before = self.groups.len();
866        self.groups
867            .retain(|group| live_message_ids.contains(group.anchor_message_id()));
868        let removed = before.saturating_sub(self.groups.len());
869        if removed > 0 {
870            self.state_revision = self.state_revision.saturating_add(1);
871            self.last_reset_reason = Some(ProviderTranscriptResetReason::Rollback);
872        }
873        removed
874    }
875
876    pub fn replayable_groups(
877        &self,
878        family: ProviderFamily,
879        protocol: ProviderProtocol,
880        provider_boundary_sha256: &str,
881    ) -> Vec<&ProviderTranscriptGroup> {
882        if self.active_family != Some(family)
883            || self.active_protocol != Some(protocol)
884            || self.active_provider_boundary_sha256.as_deref() != Some(provider_boundary_sha256)
885            || !protocol.supports_family(family)
886        {
887            return Vec::new();
888        }
889        let mut groups = self
890            .groups
891            .iter()
892            .filter(|group| {
893                group.epoch == self.epoch
894                    && group.family == family
895                    && group.protocol == protocol
896                    && group.provider_boundary_sha256 == provider_boundary_sha256
897            })
898            .collect::<Vec<_>>();
899        groups.sort_by_key(|group| group.sequence);
900        groups
901    }
902
903    /// Append-safe merge used when a stale runner is reconciled with a durable
904    /// prefix. Epoch boundaries outrank append revisions; within one family and
905    /// epoch the higher revision wins. Missing compatible groups are retained,
906    /// while a same-epoch foreign group falls back to its ordinary message.
907    pub fn merge_durable_prefix(
908        &mut self,
909        durable: &ProviderTranscriptState,
910        ordered_message_ids: &[String],
911    ) -> usize {
912        let live = self.clone();
913        let durable_is_newer = durable.epoch > live.epoch
914            || (durable.epoch == live.epoch && durable.state_revision >= live.state_revision);
915        let mut merged = if durable_is_newer {
916            durable.clone()
917        } else {
918            live.clone()
919        };
920        let live_message_ids = ordered_message_ids.iter().cloned().collect::<HashSet<_>>();
921        let message_order = ordered_message_ids
922            .iter()
923            .enumerate()
924            .map(|(index, id)| (id.as_str(), index))
925            .collect::<HashMap<_, _>>();
926        let mut seen = merged
927            .groups
928            .iter()
929            .map(|group| group.id.clone())
930            .collect::<HashSet<_>>();
931        let mut added = 0usize;
932        for group in durable.groups.iter().chain(live.groups.iter()) {
933            if live_message_ids.contains(group.anchor_message_id()) && seen.insert(group.id.clone())
934            {
935                let group = group.clone();
936                if group.epoch == merged.epoch {
937                    if merged.active_family != Some(group.family)
938                        || merged.active_protocol != Some(group.protocol)
939                        || merged.active_provider_boundary_sha256.as_deref()
940                            != Some(group.provider_boundary_sha256())
941                    {
942                        // Concurrent provider branches at the same numeric epoch
943                        // cannot safely share raw state, even when both routes use
944                        // the same provider family. Keep the durable ordinary
945                        // message, but use its normalized fallback instead of
946                        // importing a foreign native group into the winning route.
947                        continue;
948                    }
949                } else if group.epoch > merged.epoch {
950                    // The winning boundary is authoritative. A future group from
951                    // a divergent snapshot cannot be rebased without changing
952                    // the provider transcript's meaning.
953                    continue;
954                }
955                merged.groups.push(group);
956                added = added.saturating_add(1);
957            }
958        }
959        merged.prune_dangling_groups(&live_message_ids);
960
961        // Durable messages are the authoritative prefix and live-only messages
962        // are appended after it. Rebuild the active epoch's sequence from that
963        // exact message chronology instead of inheriting whichever concurrent
964        // snapshot happened to win the revision tie.
965        let before_normalization = merged.groups.clone();
966        let mut historical = Vec::new();
967        let mut current = Vec::new();
968        for group in std::mem::take(&mut merged.groups) {
969            if group.epoch == merged.epoch {
970                current.push(group);
971            } else {
972                historical.push(group);
973            }
974        }
975        current.sort_by(|left, right| {
976            message_order
977                .get(left.anchor_message_id())
978                .copied()
979                .unwrap_or(usize::MAX)
980                .cmp(
981                    &message_order
982                        .get(right.anchor_message_id())
983                        .copied()
984                        .unwrap_or(usize::MAX),
985                )
986                .then_with(|| left.sequence.cmp(&right.sequence))
987                .then_with(|| left.id.cmp(&right.id))
988        });
989        for (sequence, group) in current.iter_mut().enumerate() {
990            group.sequence = sequence as u64;
991        }
992        merged.next_sequence = current.len() as u64;
993        historical.extend(current);
994        merged.groups = historical;
995        let normalized = merged.groups != before_normalization;
996        if added > 0 || normalized {
997            merged.state_revision = merged.state_revision.saturating_add(1);
998        }
999        *self = merged;
1000        added
1001    }
1002
1003    pub fn diagnostics(
1004        &self,
1005        family: ProviderFamily,
1006        protocol: ProviderProtocol,
1007        provider_boundary_sha256: &str,
1008    ) -> ProviderTranscriptDiagnostics {
1009        let groups = self.replayable_groups(family, protocol, provider_boundary_sha256);
1010        let item_count = groups.iter().map(|group| group.items.len()).sum();
1011        let bytes = serde_json::to_vec(&groups).unwrap_or_default();
1012        ProviderTranscriptDiagnostics {
1013            group_count: groups.len(),
1014            item_count,
1015            serialized_bytes: bytes.len(),
1016            sha256: hash_bytes(TRANSCRIPT_HASH_DOMAIN, &bytes),
1017        }
1018    }
1019}
1020
1021impl Session {
1022    pub fn activate_provider_transcript_route(
1023        &mut self,
1024        family: ProviderFamily,
1025        protocol: ProviderProtocol,
1026        provider_boundary_sha256: &str,
1027    ) -> Result<bool, ProviderTranscriptError> {
1028        let changed =
1029            self.provider_transcript
1030                .activate_route(family, protocol, provider_boundary_sha256)?;
1031        if changed {
1032            self.updated_at = chrono::Utc::now();
1033        }
1034        Ok(changed)
1035    }
1036
1037    pub fn deactivate_provider_transcript_route(&mut self) -> bool {
1038        let changed = self.provider_transcript.deactivate_route();
1039        if changed {
1040            self.updated_at = chrono::Utc::now();
1041        }
1042        changed
1043    }
1044
1045    pub fn append_provider_transcript_group(
1046        &mut self,
1047        anchor_message_id: impl Into<String>,
1048        id_hint: Option<&str>,
1049        items: Vec<ProviderTranscriptItem>,
1050    ) -> Result<String, ProviderTranscriptError> {
1051        let anchor_message_id = anchor_message_id.into();
1052        if !self
1053            .messages
1054            .iter()
1055            .any(|message| message.id == anchor_message_id)
1056        {
1057            return Err(ProviderTranscriptError::InvalidAnchor);
1058        }
1059        let id = self
1060            .provider_transcript
1061            .append_group(anchor_message_id, id_hint, items)?;
1062        self.updated_at = chrono::Utc::now();
1063        Ok(id)
1064    }
1065
1066    pub fn prune_provider_transcript(&mut self) -> usize {
1067        let live_message_ids = self
1068            .messages
1069            .iter()
1070            .map(|message| message.id.clone())
1071            .collect::<HashSet<_>>();
1072        self.provider_transcript
1073            .prune_dangling_groups(&live_message_ids)
1074    }
1075
1076    pub fn invalidate_provider_transcript(&mut self, reason: ProviderTranscriptResetReason) {
1077        self.provider_transcript.invalidate(reason);
1078        self.updated_at = chrono::Utc::now();
1079    }
1080
1081    pub fn merge_provider_transcript_from_durable(&mut self, durable: &Session) -> usize {
1082        let ordered_message_ids = self
1083            .messages
1084            .iter()
1085            .map(|message| message.id.clone())
1086            .collect::<Vec<_>>();
1087        self.provider_transcript
1088            .merge_durable_prefix(&durable.provider_transcript, &ordered_message_ids)
1089    }
1090}
1091
1092fn infer_and_validate_item(
1093    protocol: ProviderProtocol,
1094    origin: ProviderTranscriptOrigin,
1095    author: ProviderTranscriptAuthor,
1096    payload: &Value,
1097) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1098    let object = payload
1099        .as_object()
1100        .ok_or(ProviderTranscriptError::PayloadNotObject)?;
1101    let item_type = object
1102        .get("type")
1103        .and_then(Value::as_str)
1104        .ok_or(ProviderTranscriptError::InvalidItem("missing item type"))?;
1105
1106    match protocol {
1107        ProviderProtocol::OpenAiResponsesV1 => {
1108            validate_openai_item(item_type, origin, author, payload)
1109        }
1110        ProviderProtocol::AnthropicMessages2023_06_01 => {
1111            validate_anthropic_item(item_type, origin, author, payload)
1112        }
1113    }
1114}
1115
1116fn reject_unknown_fields(
1117    object: &serde_json::Map<String, Value>,
1118    allowed: &[&str],
1119    label: &'static str,
1120) -> Result<(), ProviderTranscriptError> {
1121    if object
1122        .keys()
1123        .any(|field| !allowed.contains(&field.as_str()))
1124    {
1125        return Err(ProviderTranscriptError::InvalidItem(label));
1126    }
1127    Ok(())
1128}
1129
1130fn validate_allowed_callers(value: Option<&Value>) -> bool {
1131    value.is_none_or(|value| {
1132        value.is_null()
1133            || value.as_array().is_some_and(|callers| {
1134                callers
1135                    .iter()
1136                    .all(|caller| matches!(caller.as_str(), Some("direct" | "programmatic")))
1137            })
1138    })
1139}
1140
1141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1142enum OpenAiFunctionDefinitionMode {
1143    /// A definition returned by OpenAI in a hosted tool-search output. The
1144    /// generated response model permits optional fields to be absent or null.
1145    ProviderOutput,
1146    /// A callable definition Bamboo creates for client search or a
1147    /// position-scoped `additional_tools` input item. OpenAI's request model
1148    /// requires both `parameters` and `strict` keys, while permitting either
1149    /// value to be null.
1150    HostInput,
1151    /// A function nested in a namespace. OpenAI's namespace member contract
1152    /// intentionally keeps parameters/strict optional and nullable for both
1153    /// request and response projections.
1154    NamespaceMember,
1155}
1156
1157fn validate_openai_function_definition(
1158    object: &serde_json::Map<String, Value>,
1159    mode: OpenAiFunctionDefinitionMode,
1160) -> Result<(), ProviderTranscriptError> {
1161    reject_unknown_fields(
1162        object,
1163        &[
1164            "type",
1165            "name",
1166            "parameters",
1167            "strict",
1168            "allowed_callers",
1169            "defer_loading",
1170            "description",
1171            "output_schema",
1172        ],
1173        "function definition fields",
1174    )?;
1175    let parameters_valid = match mode {
1176        OpenAiFunctionDefinitionMode::HostInput => object
1177            .get("parameters")
1178            .is_some_and(|parameters| parameters.is_null() || parameters.is_object()),
1179        OpenAiFunctionDefinitionMode::ProviderOutput
1180        | OpenAiFunctionDefinitionMode::NamespaceMember => object
1181            .get("parameters")
1182            .is_none_or(|parameters| parameters.is_null() || parameters.is_object()),
1183    };
1184    let nullable_optional = |value: Option<&Value>, predicate: fn(&Value) -> bool| {
1185        value.is_none_or(|value| value.is_null() || predicate(value))
1186    };
1187    if object.get("type").and_then(Value::as_str) != Some("function")
1188        || !object
1189            .get("name")
1190            .and_then(Value::as_str)
1191            .is_some_and(|name| !name.trim().is_empty())
1192        || !parameters_valid
1193        || match mode {
1194            OpenAiFunctionDefinitionMode::HostInput => !object
1195                .get("strict")
1196                .is_some_and(|strict| strict.is_null() || strict.is_boolean()),
1197            OpenAiFunctionDefinitionMode::ProviderOutput
1198            | OpenAiFunctionDefinitionMode::NamespaceMember => {
1199                !nullable_optional(object.get("strict"), Value::is_boolean)
1200            }
1201        }
1202        || !nullable_optional(object.get("defer_loading"), Value::is_boolean)
1203        || !nullable_optional(object.get("description"), Value::is_string)
1204        || !nullable_optional(object.get("output_schema"), Value::is_object)
1205        || !validate_allowed_callers(object.get("allowed_callers"))
1206    {
1207        return Err(ProviderTranscriptError::InvalidItem(
1208            "function definition shape",
1209        ));
1210    }
1211    Ok(())
1212}
1213
1214fn validate_openai_tool_definition(
1215    value: &Value,
1216    mode: OpenAiFunctionDefinitionMode,
1217) -> Result<(), ProviderTranscriptError> {
1218    let object = value
1219        .as_object()
1220        .ok_or(ProviderTranscriptError::InvalidItem("tool definition"))?;
1221    match object.get("type").and_then(Value::as_str) {
1222        Some("function") => validate_openai_function_definition(object, mode),
1223        Some("namespace") => {
1224            reject_unknown_fields(
1225                object,
1226                &["type", "name", "description", "tools"],
1227                "namespace definition fields",
1228            )?;
1229            let tools = object
1230                .get("tools")
1231                .and_then(Value::as_array)
1232                .ok_or(ProviderTranscriptError::InvalidItem("namespace tools"))?;
1233            if !object
1234                .get("name")
1235                .and_then(Value::as_str)
1236                .is_some_and(|name| !name.trim().is_empty())
1237                || !object
1238                    .get("description")
1239                    .and_then(Value::as_str)
1240                    .is_some_and(|description| !description.trim().is_empty())
1241            {
1242                return Err(ProviderTranscriptError::InvalidItem(
1243                    "namespace definition shape",
1244                ));
1245            }
1246            for tool in tools {
1247                let nested = tool
1248                    .as_object()
1249                    .ok_or(ProviderTranscriptError::InvalidItem(
1250                        "namespace tool definition",
1251                    ))?;
1252                // Bamboo currently lowers canonical callable capabilities as
1253                // functions. Other provider tool variants must be explicitly
1254                // implemented before they can enter durable replay state.
1255                validate_openai_function_definition(
1256                    nested,
1257                    OpenAiFunctionDefinitionMode::NamespaceMember,
1258                )?;
1259            }
1260            Ok(())
1261        }
1262        _ => Err(ProviderTranscriptError::InvalidItem(
1263            "unsupported loaded tool definition",
1264        )),
1265    }
1266}
1267
1268fn validate_openai_tool_definitions(
1269    value: Option<&Value>,
1270    mode: OpenAiFunctionDefinitionMode,
1271) -> Result<(), ProviderTranscriptError> {
1272    let tools = value
1273        .and_then(Value::as_array)
1274        .ok_or(ProviderTranscriptError::InvalidItem("loaded tools"))?;
1275    for tool in tools {
1276        validate_openai_tool_definition(tool, mode)?;
1277    }
1278    Ok(())
1279}
1280
1281fn is_nonempty_string(value: Option<&Value>) -> bool {
1282    value
1283        .and_then(Value::as_str)
1284        .is_some_and(|value| !value.trim().is_empty())
1285}
1286
1287fn is_optional_nullable_nonempty_string(value: Option<&Value>) -> bool {
1288    value.is_none_or(|value| {
1289        value.is_null() || value.as_str().is_some_and(|value| !value.trim().is_empty())
1290    })
1291}
1292
1293fn is_optional_nullable_item_status(value: Option<&Value>) -> bool {
1294    value.is_none_or(|value| value.is_null() || is_openai_item_status(Some(value)))
1295}
1296
1297fn is_openai_item_status(value: Option<&Value>) -> bool {
1298    matches!(
1299        value.and_then(Value::as_str),
1300        Some("in_progress" | "completed" | "incomplete")
1301    )
1302}
1303
1304fn validate_openai_agent(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1305    let Some(value) = value else {
1306        return Ok(());
1307    };
1308    if value.is_null() {
1309        return Ok(());
1310    }
1311    let agent = value
1312        .as_object()
1313        .ok_or(ProviderTranscriptError::InvalidItem("agent shape"))?;
1314    reject_unknown_fields(agent, &["agent_name"], "agent fields")?;
1315    if !is_nonempty_string(agent.get("agent_name")) {
1316        return Err(ProviderTranscriptError::InvalidItem("agent name"));
1317    }
1318    Ok(())
1319}
1320
1321fn validate_openai_annotation(value: &Value) -> Result<(), ProviderTranscriptError> {
1322    let annotation = value
1323        .as_object()
1324        .ok_or(ProviderTranscriptError::InvalidItem("output annotation"))?;
1325    let string = |field| annotation.get(field).is_some_and(Value::is_string);
1326    let index = |field| annotation.get(field).and_then(Value::as_u64).is_some();
1327    match annotation.get("type").and_then(Value::as_str) {
1328        Some("file_citation") => {
1329            reject_unknown_fields(
1330                annotation,
1331                &["type", "file_id", "filename", "index"],
1332                "file citation fields",
1333            )?;
1334            if !string("file_id") || !string("filename") || !index("index") {
1335                return Err(ProviderTranscriptError::InvalidItem("file citation shape"));
1336            }
1337        }
1338        Some("url_citation") => {
1339            reject_unknown_fields(
1340                annotation,
1341                &["type", "start_index", "end_index", "title", "url"],
1342                "url citation fields",
1343            )?;
1344            if !index("start_index") || !index("end_index") || !string("title") || !string("url") {
1345                return Err(ProviderTranscriptError::InvalidItem("url citation shape"));
1346            }
1347        }
1348        Some("container_file_citation") => {
1349            reject_unknown_fields(
1350                annotation,
1351                &[
1352                    "type",
1353                    "container_id",
1354                    "start_index",
1355                    "end_index",
1356                    "file_id",
1357                    "filename",
1358                ],
1359                "container citation fields",
1360            )?;
1361            if !string("container_id")
1362                || !index("start_index")
1363                || !index("end_index")
1364                || !string("file_id")
1365                || !string("filename")
1366            {
1367                return Err(ProviderTranscriptError::InvalidItem(
1368                    "container citation shape",
1369                ));
1370            }
1371        }
1372        Some("file_path") => {
1373            reject_unknown_fields(
1374                annotation,
1375                &["type", "file_id", "index"],
1376                "file path fields",
1377            )?;
1378            if !string("file_id") || !index("index") {
1379                return Err(ProviderTranscriptError::InvalidItem("file path shape"));
1380            }
1381        }
1382        _ => {
1383            return Err(ProviderTranscriptError::InvalidItem(
1384                "unsupported output annotation",
1385            ))
1386        }
1387    }
1388    Ok(())
1389}
1390
1391fn validate_openai_logprob_bytes(value: Option<&Value>) -> bool {
1392    value.and_then(Value::as_array).is_some_and(|bytes| {
1393        bytes
1394            .iter()
1395            .all(|byte| byte.as_u64().is_some_and(|byte| byte <= u8::MAX as u64))
1396    })
1397}
1398
1399fn validate_openai_logprobs(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1400    let Some(value) = value else {
1401        return Ok(());
1402    };
1403    if value.is_null() {
1404        return Ok(());
1405    }
1406    let logprobs = value
1407        .as_array()
1408        .ok_or(ProviderTranscriptError::InvalidItem("output logprobs"))?;
1409    for logprob in logprobs {
1410        let logprob = logprob
1411            .as_object()
1412            .ok_or(ProviderTranscriptError::InvalidItem("output logprob"))?;
1413        reject_unknown_fields(
1414            logprob,
1415            &["token", "bytes", "logprob", "top_logprobs"],
1416            "output logprob fields",
1417        )?;
1418        let top_logprobs = logprob
1419            .get("top_logprobs")
1420            .and_then(Value::as_array)
1421            .ok_or(ProviderTranscriptError::InvalidItem("top logprobs"))?;
1422        if !logprob.get("token").is_some_and(Value::is_string)
1423            || !validate_openai_logprob_bytes(logprob.get("bytes"))
1424            || !logprob.get("logprob").is_some_and(Value::is_number)
1425        {
1426            return Err(ProviderTranscriptError::InvalidItem("output logprob shape"));
1427        }
1428        for top in top_logprobs {
1429            let top = top
1430                .as_object()
1431                .ok_or(ProviderTranscriptError::InvalidItem("top logprob"))?;
1432            reject_unknown_fields(top, &["token", "bytes", "logprob"], "top logprob fields")?;
1433            if !top.get("token").is_some_and(Value::is_string)
1434                || !validate_openai_logprob_bytes(top.get("bytes"))
1435                || !top.get("logprob").is_some_and(Value::is_number)
1436            {
1437                return Err(ProviderTranscriptError::InvalidItem("top logprob shape"));
1438            }
1439        }
1440    }
1441    Ok(())
1442}
1443
1444fn validate_openai_message_content(value: &Value) -> Result<(), ProviderTranscriptError> {
1445    let content = value
1446        .as_array()
1447        .ok_or(ProviderTranscriptError::InvalidItem("message content"))?;
1448    for part in content {
1449        let part = part
1450            .as_object()
1451            .ok_or(ProviderTranscriptError::InvalidItem("message content part"))?;
1452        match part.get("type").and_then(Value::as_str) {
1453            Some("output_text") => {
1454                reject_unknown_fields(
1455                    part,
1456                    &["type", "text", "annotations", "logprobs"],
1457                    "output text fields",
1458                )?;
1459                let annotations = part
1460                    .get("annotations")
1461                    .and_then(Value::as_array)
1462                    .ok_or(ProviderTranscriptError::InvalidItem("output annotations"))?;
1463                if !part.get("text").is_some_and(Value::is_string) {
1464                    return Err(ProviderTranscriptError::InvalidItem("output text content"));
1465                }
1466                for annotation in annotations {
1467                    validate_openai_annotation(annotation)?;
1468                }
1469                validate_openai_logprobs(part.get("logprobs"))?;
1470            }
1471            Some("refusal") => {
1472                reject_unknown_fields(part, &["type", "refusal"], "refusal fields")?;
1473                if !part.get("refusal").is_some_and(Value::is_string) {
1474                    return Err(ProviderTranscriptError::InvalidItem("refusal content"));
1475                }
1476            }
1477            _ => {
1478                return Err(ProviderTranscriptError::InvalidItem(
1479                    "unsupported message content",
1480                ))
1481            }
1482        }
1483    }
1484    Ok(())
1485}
1486
1487fn validate_openai_reasoning_parts(
1488    value: Option<&Value>,
1489    kind: &'static str,
1490    required: bool,
1491) -> Result<(), ProviderTranscriptError> {
1492    let Some(value) = value else {
1493        return if required {
1494            Err(ProviderTranscriptError::InvalidItem("reasoning parts"))
1495        } else {
1496            Ok(())
1497        };
1498    };
1499    if value.is_null() && !required {
1500        return Ok(());
1501    }
1502    let parts = value
1503        .as_array()
1504        .ok_or(ProviderTranscriptError::InvalidItem("reasoning parts"))?;
1505    for part in parts {
1506        let part = part
1507            .as_object()
1508            .ok_or(ProviderTranscriptError::InvalidItem("reasoning part"))?;
1509        reject_unknown_fields(part, &["type", "text"], "reasoning part fields")?;
1510        if part.get("type").and_then(Value::as_str) != Some(kind)
1511            || !part.get("text").is_some_and(Value::is_string)
1512        {
1513            return Err(ProviderTranscriptError::InvalidItem("reasoning part shape"));
1514        }
1515    }
1516    Ok(())
1517}
1518
1519fn validate_openai_caller(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1520    let Some(value) = value else {
1521        return Ok(());
1522    };
1523    if value.is_null() {
1524        return Ok(());
1525    }
1526    let caller = value
1527        .as_object()
1528        .ok_or(ProviderTranscriptError::InvalidItem("function caller"))?;
1529    match caller.get("type").and_then(Value::as_str) {
1530        Some("direct") => reject_unknown_fields(caller, &["type"], "direct caller fields"),
1531        Some("program") => {
1532            reject_unknown_fields(caller, &["type", "caller_id"], "program caller fields")?;
1533            if !is_nonempty_string(caller.get("caller_id")) {
1534                return Err(ProviderTranscriptError::InvalidItem("program caller id"));
1535            }
1536            Ok(())
1537        }
1538        _ => Err(ProviderTranscriptError::InvalidItem(
1539            "unsupported function caller",
1540        )),
1541    }
1542}
1543
1544fn validate_openai_item(
1545    item_type: &str,
1546    origin: ProviderTranscriptOrigin,
1547    author: ProviderTranscriptAuthor,
1548    payload: &Value,
1549) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1550    let object = payload.as_object().expect("validated object");
1551    let require_nonempty_string = |field: &'static str| {
1552        object
1553            .get(field)
1554            .and_then(Value::as_str)
1555            .filter(|value| !value.trim().is_empty())
1556            .ok_or(ProviderTranscriptError::InvalidItem(field))
1557    };
1558    let execution = || {
1559        object
1560            .get("execution")
1561            .and_then(Value::as_str)
1562            .filter(|value| matches!(*value, "server" | "client"))
1563            .ok_or(ProviderTranscriptError::InvalidItem("execution"))
1564    };
1565
1566    match item_type {
1567        "message" if origin == ProviderTranscriptOrigin::Provider => {
1568            reject_unknown_fields(
1569                object,
1570                &["type", "id", "content", "role", "status", "agent", "phase"],
1571                "provider message fields",
1572            )?;
1573            if author != ProviderTranscriptAuthor::Model
1574                || !is_nonempty_string(object.get("id"))
1575                || object.get("role").and_then(Value::as_str) != Some("assistant")
1576                || !is_openai_item_status(object.get("status"))
1577                || object.get("phase").is_some_and(|phase| {
1578                    !phase.is_null()
1579                        && !matches!(phase.as_str(), Some("commentary" | "final_answer"))
1580                })
1581            {
1582                return Err(ProviderTranscriptError::InvalidItem(
1583                    "provider message shape",
1584                ));
1585            }
1586            validate_openai_agent(object.get("agent"))?;
1587            validate_openai_message_content(object.get("content").ok_or(
1588                ProviderTranscriptError::InvalidItem("provider message content"),
1589            )?)?;
1590            Ok(ProviderTranscriptItemKind::OpenAiMessage)
1591        }
1592        "reasoning" if origin == ProviderTranscriptOrigin::Provider => {
1593            reject_unknown_fields(
1594                object,
1595                &[
1596                    "type",
1597                    "id",
1598                    "summary",
1599                    "agent",
1600                    "content",
1601                    "encrypted_content",
1602                    "status",
1603                ],
1604                "reasoning fields",
1605            )?;
1606            if author != ProviderTranscriptAuthor::Model
1607                || !is_nonempty_string(object.get("id"))
1608                || !object
1609                    .get("encrypted_content")
1610                    .is_none_or(|content| content.is_null() || content.is_string())
1611                || !is_optional_nullable_item_status(object.get("status"))
1612            {
1613                return Err(ProviderTranscriptError::InvalidItem("reasoning shape"));
1614            }
1615            validate_openai_agent(object.get("agent"))?;
1616            validate_openai_reasoning_parts(object.get("summary"), "summary_text", true)?;
1617            validate_openai_reasoning_parts(object.get("content"), "reasoning_text", false)?;
1618            Ok(ProviderTranscriptItemKind::OpenAiReasoning)
1619        }
1620        "function_call" if origin == ProviderTranscriptOrigin::Provider => {
1621            reject_unknown_fields(
1622                object,
1623                &[
1624                    "type",
1625                    "id",
1626                    "arguments",
1627                    "call_id",
1628                    "name",
1629                    "agent",
1630                    "caller",
1631                    "namespace",
1632                    "status",
1633                    "created_by",
1634                ],
1635                "function call fields",
1636            )?;
1637            require_nonempty_string("name")?;
1638            require_nonempty_string("call_id")?;
1639            let arguments = object.get("arguments").and_then(Value::as_str);
1640            if author != ProviderTranscriptAuthor::Model
1641                || !is_optional_nullable_nonempty_string(object.get("id"))
1642                || !is_optional_nullable_nonempty_string(object.get("namespace"))
1643                || !is_optional_nullable_item_status(object.get("status"))
1644                || !is_optional_nullable_nonempty_string(object.get("created_by"))
1645                || arguments.is_none()
1646                || !arguments.is_some_and(|arguments| {
1647                    serde_json::from_str::<Value>(arguments)
1648                        .ok()
1649                        .is_some_and(|value| value.is_object())
1650                })
1651            {
1652                return Err(ProviderTranscriptError::InvalidItem("function call shape"));
1653            }
1654            validate_openai_agent(object.get("agent"))?;
1655            validate_openai_caller(object.get("caller"))?;
1656            Ok(ProviderTranscriptItemKind::OpenAiFunctionCall)
1657        }
1658        "tool_search_call" if origin == ProviderTranscriptOrigin::Provider => {
1659            reject_unknown_fields(
1660                object,
1661                &[
1662                    "type",
1663                    "id",
1664                    "arguments",
1665                    "call_id",
1666                    "execution",
1667                    "status",
1668                    "agent",
1669                    "created_by",
1670                ],
1671                "tool search call fields",
1672            )?;
1673            let execution = execution()?;
1674            require_nonempty_string("id")?;
1675            let call_id_valid = if execution == "client" {
1676                is_nonempty_string(object.get("call_id"))
1677            } else {
1678                is_optional_nullable_nonempty_string(object.get("call_id"))
1679            };
1680            if author != ProviderTranscriptAuthor::Model
1681                || object.get("status").and_then(Value::as_str) != Some("completed")
1682                || !object.get("arguments").is_some_and(Value::is_object)
1683                || !call_id_valid
1684                || !is_optional_nullable_nonempty_string(object.get("created_by"))
1685            {
1686                return Err(ProviderTranscriptError::InvalidItem(
1687                    "tool search call shape",
1688                ));
1689            }
1690            validate_openai_agent(object.get("agent"))?;
1691            Ok(ProviderTranscriptItemKind::OpenAiToolSearchCall)
1692        }
1693        "tool_search_output" => {
1694            let execution = execution()?;
1695            if author != ProviderTranscriptAuthor::ToolResult
1696                || object.get("status").and_then(Value::as_str) != Some("completed")
1697            {
1698                return Err(ProviderTranscriptError::InvalidItem(
1699                    "tool search output shape",
1700                ));
1701            }
1702            match (execution, origin) {
1703                ("server", ProviderTranscriptOrigin::Provider) => {
1704                    reject_unknown_fields(
1705                        object,
1706                        &[
1707                            "type",
1708                            "id",
1709                            "call_id",
1710                            "execution",
1711                            "status",
1712                            "tools",
1713                            "agent",
1714                            "created_by",
1715                        ],
1716                        "server tool search output fields",
1717                    )?;
1718                    require_nonempty_string("id")?;
1719                    if !is_optional_nullable_nonempty_string(object.get("call_id"))
1720                        || !is_optional_nullable_nonempty_string(object.get("created_by"))
1721                    {
1722                        return Err(ProviderTranscriptError::InvalidItem(
1723                            "tool search output metadata",
1724                        ));
1725                    }
1726                    validate_openai_agent(object.get("agent"))?;
1727                    validate_openai_tool_definitions(
1728                        object.get("tools"),
1729                        OpenAiFunctionDefinitionMode::ProviderOutput,
1730                    )?;
1731                }
1732                ("client", ProviderTranscriptOrigin::HostToolSearch) => {
1733                    reject_unknown_fields(
1734                        object,
1735                        &["type", "call_id", "execution", "status", "tools"],
1736                        "client tool search output fields",
1737                    )?;
1738                    require_nonempty_string("call_id")?;
1739                    validate_openai_tool_definitions(
1740                        object.get("tools"),
1741                        OpenAiFunctionDefinitionMode::HostInput,
1742                    )?;
1743                }
1744                _ => {
1745                    return Err(ProviderTranscriptError::InvalidItem(
1746                        "tool search output origin",
1747                    ))
1748                }
1749            }
1750            Ok(ProviderTranscriptItemKind::OpenAiToolSearchOutput)
1751        }
1752        "additional_tools" if origin == ProviderTranscriptOrigin::DeveloperContext => {
1753            reject_unknown_fields(
1754                object,
1755                &["type", "role", "tools"],
1756                "additional tools fields",
1757            )?;
1758            if author != ProviderTranscriptAuthor::Host
1759                || object.get("role").and_then(Value::as_str) != Some("developer")
1760            {
1761                return Err(ProviderTranscriptError::InvalidItem(
1762                    "additional tools shape",
1763                ));
1764            }
1765            validate_openai_tool_definitions(
1766                object.get("tools"),
1767                OpenAiFunctionDefinitionMode::HostInput,
1768            )?;
1769            Ok(ProviderTranscriptItemKind::OpenAiAdditionalTools)
1770        }
1771        _ => Err(ProviderTranscriptError::UnsupportedItemType),
1772    }
1773}
1774
1775fn validate_anthropic_caller(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1776    let Some(value) = value else {
1777        return Ok(());
1778    };
1779    let caller = value
1780        .as_object()
1781        .ok_or(ProviderTranscriptError::InvalidItem("Anthropic caller"))?;
1782    match caller.get("type").and_then(Value::as_str) {
1783        Some("direct") => reject_unknown_fields(caller, &["type"], "direct caller fields"),
1784        Some("code_execution_20250825" | "code_execution_20260120" | "code_execution_20260521") => {
1785            reject_unknown_fields(caller, &["type", "tool_id"], "server caller fields")?;
1786            if !is_nonempty_string(caller.get("tool_id")) {
1787                return Err(ProviderTranscriptError::InvalidItem(
1788                    "server caller tool id",
1789                ));
1790            }
1791            Ok(())
1792        }
1793        _ => Err(ProviderTranscriptError::InvalidItem(
1794            "unsupported Anthropic caller",
1795        )),
1796    }
1797}
1798
1799fn is_string_or_null(value: Option<&Value>) -> bool {
1800    value.is_some_and(|value| value.is_string() || value.is_null())
1801}
1802
1803fn validate_anthropic_text_citation(value: &Value) -> Result<(), ProviderTranscriptError> {
1804    let citation = value
1805        .as_object()
1806        .ok_or(ProviderTranscriptError::InvalidItem("text citation"))?;
1807    let string = |field| citation.get(field).is_some_and(Value::is_string);
1808    let index = |field| citation.get(field).and_then(Value::as_u64).is_some();
1809    match citation.get("type").and_then(Value::as_str) {
1810        Some("char_location") => {
1811            reject_unknown_fields(
1812                citation,
1813                &[
1814                    "type",
1815                    "cited_text",
1816                    "document_index",
1817                    "document_title",
1818                    "start_char_index",
1819                    "end_char_index",
1820                    "file_id",
1821                ],
1822                "char citation fields",
1823            )?;
1824            if !string("cited_text")
1825                || !index("document_index")
1826                || !is_string_or_null(citation.get("document_title"))
1827                || !index("start_char_index")
1828                || !index("end_char_index")
1829                || !is_string_or_null(citation.get("file_id"))
1830            {
1831                return Err(ProviderTranscriptError::InvalidItem("char citation shape"));
1832            }
1833        }
1834        Some("page_location") => {
1835            reject_unknown_fields(
1836                citation,
1837                &[
1838                    "type",
1839                    "cited_text",
1840                    "document_index",
1841                    "document_title",
1842                    "start_page_number",
1843                    "end_page_number",
1844                    "file_id",
1845                ],
1846                "page citation fields",
1847            )?;
1848            if !string("cited_text")
1849                || !index("document_index")
1850                || !is_string_or_null(citation.get("document_title"))
1851                || !index("start_page_number")
1852                || !index("end_page_number")
1853                || !is_string_or_null(citation.get("file_id"))
1854            {
1855                return Err(ProviderTranscriptError::InvalidItem("page citation shape"));
1856            }
1857        }
1858        Some("content_block_location") => {
1859            reject_unknown_fields(
1860                citation,
1861                &[
1862                    "type",
1863                    "cited_text",
1864                    "document_index",
1865                    "document_title",
1866                    "start_block_index",
1867                    "end_block_index",
1868                    "file_id",
1869                ],
1870                "content citation fields",
1871            )?;
1872            if !string("cited_text")
1873                || !index("document_index")
1874                || !is_string_or_null(citation.get("document_title"))
1875                || !index("start_block_index")
1876                || !index("end_block_index")
1877                || !is_string_or_null(citation.get("file_id"))
1878            {
1879                return Err(ProviderTranscriptError::InvalidItem(
1880                    "content citation shape",
1881                ));
1882            }
1883        }
1884        Some("web_search_result_location") => {
1885            reject_unknown_fields(
1886                citation,
1887                &["type", "cited_text", "encrypted_index", "title", "url"],
1888                "web citation fields",
1889            )?;
1890            if !string("cited_text")
1891                || !string("encrypted_index")
1892                || !is_string_or_null(citation.get("title"))
1893                || !string("url")
1894            {
1895                return Err(ProviderTranscriptError::InvalidItem("web citation shape"));
1896            }
1897        }
1898        Some("search_result_location") => {
1899            reject_unknown_fields(
1900                citation,
1901                &[
1902                    "type",
1903                    "cited_text",
1904                    "start_block_index",
1905                    "end_block_index",
1906                    "search_result_index",
1907                    "source",
1908                    "title",
1909                ],
1910                "search citation fields",
1911            )?;
1912            if !string("cited_text")
1913                || !index("start_block_index")
1914                || !index("end_block_index")
1915                || !index("search_result_index")
1916                || !string("source")
1917                || !is_string_or_null(citation.get("title"))
1918            {
1919                return Err(ProviderTranscriptError::InvalidItem(
1920                    "search citation shape",
1921                ));
1922            }
1923        }
1924        _ => {
1925            return Err(ProviderTranscriptError::InvalidItem(
1926                "unsupported text citation",
1927            ))
1928        }
1929    }
1930    Ok(())
1931}
1932
1933fn validate_anthropic_text_citations(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1934    let Some(value) = value else {
1935        return Ok(());
1936    };
1937    if value.is_null() {
1938        return Ok(());
1939    }
1940    let citations = value
1941        .as_array()
1942        .ok_or(ProviderTranscriptError::InvalidItem("text citations"))?;
1943    for citation in citations {
1944        validate_anthropic_text_citation(citation)?;
1945    }
1946    Ok(())
1947}
1948
1949fn validate_anthropic_tool_search_input(
1950    name: &str,
1951    value: Option<&Value>,
1952) -> Result<(), ProviderTranscriptError> {
1953    let input = value
1954        .and_then(Value::as_object)
1955        .ok_or(ProviderTranscriptError::InvalidItem(
1956            "tool-search server input",
1957        ))?;
1958    let (query_field, maximum_chars) = match name {
1959        "tool_search_tool_regex" => ("pattern", 200usize),
1960        "tool_search_tool_bm25" => ("query", 500usize),
1961        _ => {
1962            return Err(ProviderTranscriptError::InvalidItem(
1963                "tool-search server name",
1964            ))
1965        }
1966    };
1967    reject_unknown_fields(
1968        input,
1969        &[query_field, "limit"],
1970        "tool-search server input fields",
1971    )?;
1972    let query = input.get(query_field).and_then(Value::as_str).ok_or(
1973        ProviderTranscriptError::InvalidItem("tool-search server query"),
1974    )?;
1975    if query.chars().count() > maximum_chars {
1976        return Err(ProviderTranscriptError::InvalidItem(
1977            "tool-search server query length",
1978        ));
1979    }
1980    if input.get("limit").is_some_and(|limit| {
1981        !limit
1982            .as_u64()
1983            .is_some_and(|limit| (1..=10_000).contains(&limit))
1984    }) {
1985        return Err(ProviderTranscriptError::InvalidItem(
1986            "tool-search server result limit",
1987        ));
1988    }
1989    Ok(())
1990}
1991
1992fn validate_anthropic_item(
1993    item_type: &str,
1994    origin: ProviderTranscriptOrigin,
1995    author: ProviderTranscriptAuthor,
1996    payload: &Value,
1997) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1998    let object = payload.as_object().expect("validated object");
1999    let nonempty = |field: &'static str| {
2000        object
2001            .get(field)
2002            .and_then(Value::as_str)
2003            .filter(|value| !value.trim().is_empty())
2004            .ok_or(ProviderTranscriptError::InvalidItem(field))
2005    };
2006    match item_type {
2007        "text" if origin == ProviderTranscriptOrigin::Provider => {
2008            reject_unknown_fields(object, &["type", "text", "citations"], "text block fields")?;
2009            if author != ProviderTranscriptAuthor::Model
2010                || !object.get("text").is_some_and(Value::is_string)
2011            {
2012                return Err(ProviderTranscriptError::InvalidItem("text block"));
2013            }
2014            validate_anthropic_text_citations(object.get("citations"))?;
2015            Ok(ProviderTranscriptItemKind::AnthropicText)
2016        }
2017        "thinking" if origin == ProviderTranscriptOrigin::Provider => {
2018            reject_unknown_fields(
2019                object,
2020                &["type", "thinking", "signature"],
2021                "thinking block fields",
2022            )?;
2023            if author != ProviderTranscriptAuthor::Model
2024                || !object.get("thinking").is_some_and(Value::is_string)
2025                || !object
2026                    .get("signature")
2027                    .and_then(Value::as_str)
2028                    .is_some_and(|signature| !signature.trim().is_empty())
2029            {
2030                return Err(ProviderTranscriptError::InvalidItem("thinking block"));
2031            }
2032            Ok(ProviderTranscriptItemKind::AnthropicThinking)
2033        }
2034        "redacted_thinking" if origin == ProviderTranscriptOrigin::Provider => {
2035            reject_unknown_fields(object, &["type", "data"], "redacted thinking fields")?;
2036            if author != ProviderTranscriptAuthor::Model
2037                || !object.get("data").is_some_and(Value::is_string)
2038            {
2039                return Err(ProviderTranscriptError::InvalidItem(
2040                    "redacted thinking block",
2041                ));
2042            }
2043            Ok(ProviderTranscriptItemKind::AnthropicRedactedThinking)
2044        }
2045        "server_tool_use" if origin == ProviderTranscriptOrigin::Provider => {
2046            reject_unknown_fields(
2047                object,
2048                &["type", "id", "name", "input", "caller"],
2049                "server tool use fields",
2050            )?;
2051            nonempty("id")?;
2052            let name = nonempty("name")?;
2053            if author != ProviderTranscriptAuthor::Model
2054                || !matches!(name, "tool_search_tool_regex" | "tool_search_tool_bm25")
2055            {
2056                return Err(ProviderTranscriptError::InvalidItem(
2057                    "tool-search server_tool_use block",
2058                ));
2059            }
2060            validate_anthropic_tool_search_input(name, object.get("input"))?;
2061            validate_anthropic_caller(object.get("caller"))?;
2062            Ok(ProviderTranscriptItemKind::AnthropicServerToolUse)
2063        }
2064        "tool_search_tool_result" if origin == ProviderTranscriptOrigin::Provider => {
2065            reject_unknown_fields(
2066                object,
2067                &["type", "tool_use_id", "content"],
2068                "tool search result fields",
2069            )?;
2070            nonempty("tool_use_id")?;
2071            if author != ProviderTranscriptAuthor::ToolResult {
2072                return Err(ProviderTranscriptError::InvalidItem(
2073                    "tool search result author",
2074                ));
2075            }
2076            validate_anthropic_search_result_content(object.get("content"))?;
2077            Ok(ProviderTranscriptItemKind::AnthropicToolSearchToolResult)
2078        }
2079        "tool_use" if origin == ProviderTranscriptOrigin::Provider => {
2080            reject_unknown_fields(
2081                object,
2082                &["type", "id", "name", "input", "caller", "toolset_name"],
2083                "tool use fields",
2084            )?;
2085            nonempty("id")?;
2086            nonempty("name")?;
2087            if author != ProviderTranscriptAuthor::Model
2088                || !object.get("input").is_some_and(Value::is_object)
2089                || object.get("toolset_name").is_some_and(|toolset| {
2090                    !(toolset.is_null()
2091                        || toolset.as_str().is_some_and(|name| !name.trim().is_empty()))
2092                })
2093            {
2094                return Err(ProviderTranscriptError::InvalidItem("tool_use block"));
2095            }
2096            validate_anthropic_caller(object.get("caller"))?;
2097            Ok(ProviderTranscriptItemKind::AnthropicToolUse)
2098        }
2099        "tool_result" if origin == ProviderTranscriptOrigin::HostToolSearch => {
2100            reject_unknown_fields(
2101                object,
2102                &["type", "tool_use_id", "content", "is_error"],
2103                "custom tool result fields",
2104            )?;
2105            nonempty("tool_use_id")?;
2106            let Some(content) = object.get("content").and_then(Value::as_array) else {
2107                return Err(ProviderTranscriptError::InvalidItem(
2108                    "custom tool search result content",
2109                ));
2110            };
2111            if author != ProviderTranscriptAuthor::ToolResult
2112                || object
2113                    .get("is_error")
2114                    .is_some_and(|is_error| is_error.as_bool().is_none_or(|is_error| is_error))
2115                || content.iter().any(|reference| {
2116                    let Some(reference) = reference.as_object() else {
2117                        return true;
2118                    };
2119                    reference.len() != 2
2120                        || reference.get("type").and_then(Value::as_str) != Some("tool_reference")
2121                        || !reference
2122                            .get("tool_name")
2123                            .and_then(Value::as_str)
2124                            .is_some_and(|name| !name.trim().is_empty())
2125                })
2126            {
2127                return Err(ProviderTranscriptError::InvalidItem(
2128                    "custom tool references",
2129                ));
2130            }
2131            Ok(ProviderTranscriptItemKind::AnthropicToolResult)
2132        }
2133        _ => Err(ProviderTranscriptError::UnsupportedItemType),
2134    }
2135}
2136
2137fn validate_anthropic_search_result_content(
2138    content: Option<&Value>,
2139) -> Result<(), ProviderTranscriptError> {
2140    let Some(content) = content.and_then(Value::as_object) else {
2141        return Err(ProviderTranscriptError::InvalidItem(
2142            "tool search result content",
2143        ));
2144    };
2145    match content.get("type").and_then(Value::as_str) {
2146        Some("tool_search_tool_search_result") => {
2147            reject_unknown_fields(
2148                content,
2149                &["type", "tool_references"],
2150                "tool reference result fields",
2151            )?;
2152            let Some(references) = content.get("tool_references").and_then(Value::as_array) else {
2153                return Err(ProviderTranscriptError::InvalidItem("tool references"));
2154            };
2155            if references.iter().any(|reference| {
2156                let Some(reference) = reference.as_object() else {
2157                    return true;
2158                };
2159                reference.len() != 2
2160                    || reference.get("type").and_then(Value::as_str) != Some("tool_reference")
2161                    || !reference
2162                        .get("tool_name")
2163                        .and_then(Value::as_str)
2164                        .is_some_and(|name| !name.trim().is_empty())
2165            }) {
2166                return Err(ProviderTranscriptError::InvalidItem("tool reference"));
2167            }
2168            Ok(())
2169        }
2170        Some("tool_search_tool_result_error") => {
2171            reject_unknown_fields(
2172                content,
2173                &["type", "error_code", "error_message"],
2174                "tool search error fields",
2175            )?;
2176            if !matches!(
2177                content.get("error_code").and_then(Value::as_str),
2178                Some(
2179                    "invalid_tool_input"
2180                        | "unavailable"
2181                        | "too_many_requests"
2182                        | "execution_time_exceeded"
2183                )
2184            ) || !is_string_or_null(content.get("error_message"))
2185            {
2186                return Err(ProviderTranscriptError::InvalidItem(
2187                    "tool search error shape",
2188                ));
2189            }
2190            Ok(())
2191        }
2192        _ => Err(ProviderTranscriptError::InvalidItem(
2193            "tool search result content type",
2194        )),
2195    }
2196}
2197
2198fn validated_group_identity(
2199    items: &[ProviderTranscriptItem],
2200) -> Result<(ProviderFamily, ProviderProtocol), ProviderTranscriptError> {
2201    let Some(first) = items.first() else {
2202        return Err(ProviderTranscriptError::EmptyGroup);
2203    };
2204    let family = first.family;
2205    let protocol = first.protocol;
2206    if items
2207        .iter()
2208        .any(|item| item.family != family || item.protocol != protocol)
2209    {
2210        return Err(ProviderTranscriptError::MixedProviderGroup);
2211    }
2212    if !items.iter().any(|item| item.kind.is_discovery()) {
2213        return Err(ProviderTranscriptError::MissingDiscoveryItem);
2214    }
2215    validate_group_order(protocol, items)?;
2216    Ok((family, protocol))
2217}
2218
2219fn validate_group_order(
2220    protocol: ProviderProtocol,
2221    items: &[ProviderTranscriptItem],
2222) -> Result<(), ProviderTranscriptError> {
2223    match protocol {
2224        ProviderProtocol::OpenAiResponsesV1 => {
2225            let mut seen_item_ids = HashSet::<&str>::new();
2226            let mut seen_search_call_ids = HashSet::<&str>::new();
2227            let mut seen_search_outputs = HashSet::<(&str, &str)>::new();
2228            let mut pending_provider_calls = HashSet::<&str>::new();
2229            let mut pending_client_calls = HashSet::<&str>::new();
2230            let mut pending_unkeyed_provider_calls = 0usize;
2231            let mut has_client_search_call = false;
2232            let mut has_client_search_output = false;
2233            let mut loaded_at = HashMap::<String, usize>::new();
2234            let mut function_calls = Vec::<(usize, &str)>::new();
2235            for (index, item) in items.iter().enumerate() {
2236                if let Some(id) = item.payload.get("id").and_then(Value::as_str) {
2237                    if !seen_item_ids.insert(id) {
2238                        return Err(ProviderTranscriptError::InvalidGroupOrder);
2239                    }
2240                }
2241                match item.kind {
2242                    ProviderTranscriptItemKind::OpenAiToolSearchCall => {
2243                        let execution = item
2244                            .payload
2245                            .get("execution")
2246                            .and_then(Value::as_str)
2247                            .unwrap_or_default();
2248                        let call_id = item
2249                            .payload
2250                            .get("call_id")
2251                            .and_then(Value::as_str)
2252                            .unwrap_or_default();
2253                        if call_id.is_empty() {
2254                            if execution == "server" {
2255                                pending_unkeyed_provider_calls =
2256                                    pending_unkeyed_provider_calls.saturating_add(1);
2257                            } else {
2258                                return Err(ProviderTranscriptError::InvalidGroupOrder);
2259                            }
2260                        } else {
2261                            if !seen_search_call_ids.insert(call_id) {
2262                                return Err(ProviderTranscriptError::InvalidGroupOrder);
2263                            }
2264                            if execution == "server" {
2265                                pending_provider_calls.insert(call_id);
2266                            } else {
2267                                pending_client_calls.insert(call_id);
2268                            }
2269                        }
2270                        has_client_search_call |= execution == "client";
2271                    }
2272                    ProviderTranscriptItemKind::OpenAiToolSearchOutput => {
2273                        let execution = item
2274                            .payload
2275                            .get("execution")
2276                            .and_then(Value::as_str)
2277                            .unwrap_or_default();
2278                        let call_id = item
2279                            .payload
2280                            .get("call_id")
2281                            .and_then(Value::as_str)
2282                            .unwrap_or_default();
2283                        if !call_id.is_empty() && !seen_search_outputs.insert((execution, call_id))
2284                        {
2285                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2286                        }
2287                        let matched_pending = if call_id.is_empty() {
2288                            if execution != "server" || pending_unkeyed_provider_calls == 0 {
2289                                false
2290                            } else {
2291                                pending_unkeyed_provider_calls =
2292                                    pending_unkeyed_provider_calls.saturating_sub(1);
2293                                true
2294                            }
2295                        } else {
2296                            let pending = if execution == "server" {
2297                                &mut pending_provider_calls
2298                            } else {
2299                                &mut pending_client_calls
2300                            };
2301                            pending.remove(call_id)
2302                        };
2303                        has_client_search_output |= execution == "client";
2304                        if !matched_pending
2305                            && (execution == "server"
2306                                || item.origin != ProviderTranscriptOrigin::HostToolSearch)
2307                        {
2308                            // A client output can be appended in a later host
2309                            // group after the original model call was committed.
2310                            // Hosted/server outputs must always complete a call
2311                            // in this same provider-owned atomic response.
2312                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2313                        }
2314                        for name in openai_loaded_tool_names(&item.payload) {
2315                            loaded_at.entry(name).or_insert(index);
2316                        }
2317                    }
2318                    ProviderTranscriptItemKind::OpenAiFunctionCall => {
2319                        let name = item
2320                            .payload
2321                            .get("name")
2322                            .and_then(Value::as_str)
2323                            .unwrap_or_default();
2324                        function_calls.push((index, name));
2325                    }
2326                    _ => {}
2327                }
2328            }
2329            if !pending_provider_calls.is_empty() || pending_unkeyed_provider_calls != 0 {
2330                return Err(ProviderTranscriptError::InvalidGroupOrder);
2331            }
2332            // Client execution is a suspension boundary: the provider-owned
2333            // response stops at tool_search_call, and the host resumes it in a
2334            // later standalone HostToolSearch output group. Express this as an
2335            // order-independent group invariant so reversed malformed output
2336            // cannot bypass a forward-only state check.
2337            if has_client_search_call && (has_client_search_output || !function_calls.is_empty()) {
2338                return Err(ProviderTranscriptError::InvalidGroupOrder);
2339            }
2340            let mut matched_loaded_call = false;
2341            for (index, name) in &function_calls {
2342                if let Some(output_index) = loaded_at.get(*name) {
2343                    if index <= output_index {
2344                        return Err(ProviderTranscriptError::InvalidGroupOrder);
2345                    }
2346                    matched_loaded_call = true;
2347                }
2348            }
2349            if !loaded_at.is_empty() && !function_calls.is_empty() && !matched_loaded_call {
2350                return Err(ProviderTranscriptError::InvalidGroupOrder);
2351            }
2352        }
2353        ProviderProtocol::AnthropicMessages2023_06_01 => {
2354            let mut server_ids = HashSet::new();
2355            let mut all_tool_use_ids = HashSet::new();
2356            let mut completed_server_ids = HashSet::new();
2357            let mut referenced_at = HashMap::<String, usize>::new();
2358            let mut tool_uses = Vec::<(usize, &str)>::new();
2359            let mut thinking_blocks = 0usize;
2360            for (index, item) in items.iter().enumerate() {
2361                match item.kind {
2362                    ProviderTranscriptItemKind::AnthropicThinking
2363                    | ProviderTranscriptItemKind::AnthropicRedactedThinking => {
2364                        thinking_blocks = thinking_blocks.saturating_add(1);
2365                        if index != 0 || thinking_blocks != 1 {
2366                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2367                        }
2368                    }
2369                    ProviderTranscriptItemKind::AnthropicServerToolUse => {
2370                        if let Some(id) = item.payload.get("id").and_then(Value::as_str) {
2371                            if !server_ids.insert(id.to_string())
2372                                || !all_tool_use_ids.insert(id.to_string())
2373                            {
2374                                return Err(ProviderTranscriptError::InvalidGroupOrder);
2375                            }
2376                        }
2377                    }
2378                    ProviderTranscriptItemKind::AnthropicToolSearchToolResult => {
2379                        let tool_use_id = item
2380                            .payload
2381                            .get("tool_use_id")
2382                            .and_then(Value::as_str)
2383                            .unwrap_or_default();
2384                        if !server_ids.contains(tool_use_id)
2385                            || !completed_server_ids.insert(tool_use_id.to_string())
2386                        {
2387                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2388                        }
2389                        if let Some(references) = item
2390                            .payload
2391                            .get("content")
2392                            .and_then(|content| content.get("tool_references"))
2393                            .and_then(Value::as_array)
2394                        {
2395                            for reference in references {
2396                                if let Some(name) =
2397                                    reference.get("tool_name").and_then(Value::as_str)
2398                                {
2399                                    referenced_at.entry(name.to_string()).or_insert(index);
2400                                }
2401                            }
2402                        }
2403                    }
2404                    ProviderTranscriptItemKind::AnthropicToolUse => {
2405                        let id = item
2406                            .payload
2407                            .get("id")
2408                            .and_then(Value::as_str)
2409                            .unwrap_or_default();
2410                        if !all_tool_use_ids.insert(id.to_string()) {
2411                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2412                        }
2413                        let name = item
2414                            .payload
2415                            .get("name")
2416                            .and_then(Value::as_str)
2417                            .unwrap_or_default();
2418                        tool_uses.push((index, name));
2419                    }
2420                    _ => {}
2421                }
2422            }
2423            if completed_server_ids.len() != server_ids.len() {
2424                return Err(ProviderTranscriptError::InvalidGroupOrder);
2425            }
2426            let mut matched_reference_use = false;
2427            for (index, name) in &tool_uses {
2428                if let Some(result_index) = referenced_at.get(*name) {
2429                    if index <= result_index {
2430                        return Err(ProviderTranscriptError::InvalidGroupOrder);
2431                    }
2432                    matched_reference_use = true;
2433                }
2434            }
2435            if !referenced_at.is_empty() && !tool_uses.is_empty() && !matched_reference_use {
2436                return Err(ProviderTranscriptError::InvalidGroupOrder);
2437            }
2438        }
2439    }
2440    Ok(())
2441}
2442
2443fn openai_loaded_tool_names(payload: &Value) -> Vec<String> {
2444    let Some(tools) = payload.get("tools").and_then(Value::as_array) else {
2445        return Vec::new();
2446    };
2447    let mut names = Vec::new();
2448    for tool in tools {
2449        match tool.get("type").and_then(Value::as_str) {
2450            Some("function") => {
2451                if let Some(name) = tool.get("name").and_then(Value::as_str) {
2452                    names.push(name.to_string());
2453                }
2454            }
2455            Some("namespace") => {
2456                let namespace = tool.get("name").and_then(Value::as_str).unwrap_or_default();
2457                if let Some(functions) = tool.get("tools").and_then(Value::as_array) {
2458                    for function in functions {
2459                        if let Some(name) = function.get("name").and_then(Value::as_str) {
2460                            names.push(name.to_string());
2461                            names.push(format!("{namespace}.{name}"));
2462                        }
2463                    }
2464                }
2465            }
2466            _ => {}
2467        }
2468    }
2469    names
2470}
2471
2472/// Extract function identities only from validated OpenAI Responses
2473/// `tool_search_output.tools` items. Ordinary function calls do not contribute
2474/// loading state.
2475pub fn validated_openai_loaded_tool_names<'a>(
2476    groups: impl IntoIterator<Item = &'a ProviderTranscriptGroup>,
2477    family: ProviderFamily,
2478) -> Vec<String> {
2479    if !matches!(family, ProviderFamily::OpenAi | ProviderFamily::Copilot) {
2480        return Vec::new();
2481    }
2482    let mut names = groups
2483        .into_iter()
2484        .filter(|group| {
2485            group.family() == family
2486                && group.protocol() == ProviderProtocol::OpenAiResponsesV1
2487                && ProviderTranscriptGroup::validate_items(group.items()).is_ok()
2488        })
2489        .flat_map(ProviderTranscriptGroup::items)
2490        .filter(|item| item.kind() == ProviderTranscriptItemKind::OpenAiToolSearchOutput)
2491        .flat_map(|item| openai_loaded_tool_names(item.payload()))
2492        .collect::<Vec<_>>();
2493    names.sort();
2494    names.dedup();
2495    names
2496}
2497
2498fn stable_item_id(
2499    family: ProviderFamily,
2500    protocol: ProviderProtocol,
2501    origin: ProviderTranscriptOrigin,
2502    author: ProviderTranscriptAuthor,
2503    kind: ProviderTranscriptItemKind,
2504    payload: &Value,
2505) -> Result<String, ProviderTranscriptError> {
2506    if !payload.is_object() {
2507        return Err(ProviderTranscriptError::PayloadNotObject);
2508    }
2509    let identity = serde_json::json!({
2510        "family": family,
2511        "protocol": protocol,
2512        "origin": origin,
2513        "author": author,
2514        "kind": kind,
2515        "payload": payload,
2516    });
2517    let digest = hash_json(ITEM_HASH_DOMAIN, &identity);
2518    // Provider ids can be attacker-controlled and may contain prompt or secret
2519    // bytes. Persist/log only the structural kind plus a one-way payload hash.
2520    Ok(format!("pti_{kind:?}_{digest}"))
2521}
2522
2523fn stable_group_id(
2524    epoch: u64,
2525    family: ProviderFamily,
2526    protocol: ProviderProtocol,
2527    provider_boundary_sha256: &str,
2528    anchor_message_id: &str,
2529    _id_hint: Option<&str>,
2530    items: &[ProviderTranscriptItem],
2531) -> String {
2532    let mut hasher = Sha256::new();
2533    hasher.update(GROUP_HASH_DOMAIN);
2534    hasher.update(epoch.to_be_bytes());
2535    hasher.update([0]);
2536    hasher.update(serde_json::to_vec(&family).unwrap_or_default());
2537    hasher.update([0]);
2538    hasher.update(serde_json::to_vec(&protocol).unwrap_or_default());
2539    hasher.update([0]);
2540    hasher.update(provider_boundary_sha256.as_bytes());
2541    hasher.update([0]);
2542    hasher.update(anchor_message_id.as_bytes());
2543    hasher.update([0]);
2544    for item in items {
2545        hasher.update([0]);
2546        hasher.update(item.id.as_bytes());
2547    }
2548    format!("ptg_{}", hex::encode(hasher.finalize()))
2549}
2550
2551fn hash_json(domain: &[u8], value: &Value) -> String {
2552    hash_bytes(
2553        domain,
2554        &serde_json::to_vec(&canonical_json(value)).unwrap_or_default(),
2555    )
2556}
2557
2558fn canonical_json(value: &Value) -> Value {
2559    match value {
2560        Value::Object(object) => {
2561            let mut entries = object.iter().collect::<Vec<_>>();
2562            entries.sort_by_key(|(key, _)| *key);
2563            Value::Object(
2564                entries
2565                    .into_iter()
2566                    .map(|(key, value)| (key.clone(), canonical_json(value)))
2567                    .collect(),
2568            )
2569        }
2570        Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()),
2571        _ => value.clone(),
2572    }
2573}
2574
2575fn hash_bytes(domain: &[u8], bytes: &[u8]) -> String {
2576    let mut hasher = Sha256::new();
2577    hasher.update(domain);
2578    hasher.update(bytes);
2579    hex::encode(hasher.finalize())
2580}
2581
2582#[cfg(test)]
2583mod tests {
2584    use serde_json::json;
2585
2586    use super::*;
2587    use crate::Message;
2588
2589    fn test_boundary(provider_name: &str, provider_type: &str) -> String {
2590        provider_transcript_boundary_sha256(Some(provider_name), Some(provider_type)).unwrap()
2591    }
2592
2593    fn openai_boundary() -> String {
2594        test_boundary("openai-test", "openai")
2595    }
2596
2597    fn anthropic_boundary() -> String {
2598        test_boundary("anthropic-test", "anthropic")
2599    }
2600
2601    fn activate_openai(session: &mut Session) {
2602        session
2603            .activate_provider_transcript_route(
2604                ProviderFamily::OpenAi,
2605                ProviderProtocol::OpenAiResponsesV1,
2606                &openai_boundary(),
2607            )
2608            .unwrap();
2609    }
2610
2611    fn activate_anthropic(session: &mut Session) -> bool {
2612        session
2613            .activate_provider_transcript_route(
2614                ProviderFamily::Anthropic,
2615                ProviderProtocol::AnthropicMessages2023_06_01,
2616                &anthropic_boundary(),
2617            )
2618            .unwrap()
2619    }
2620
2621    fn replayable_openai(session: &Session) -> Vec<&ProviderTranscriptGroup> {
2622        let boundary = session
2623            .provider_transcript
2624            .active_provider_boundary_sha256()
2625            .expect("provider transcript test route must be active");
2626        session.provider_transcript.replayable_groups(
2627            ProviderFamily::OpenAi,
2628            ProviderProtocol::OpenAiResponsesV1,
2629            boundary,
2630        )
2631    }
2632
2633    fn test_group(
2634        anchor_message_id: impl Into<String>,
2635        items: Vec<ProviderTranscriptItem>,
2636    ) -> Result<ProviderTranscriptGroup, ProviderTranscriptError> {
2637        ProviderTranscriptGroup::new(
2638            0,
2639            0,
2640            anchor_message_id.into(),
2641            None,
2642            unbound_provider_boundary_sha256(),
2643            items,
2644        )
2645    }
2646
2647    fn openai_output_items() -> Vec<ProviderTranscriptItem> {
2648        [
2649            (
2650                ProviderTranscriptAuthor::Model,
2651                json!({
2652                    "type": "tool_search_call",
2653                    "id": "tsc_1",
2654                    "execution": "server",
2655                    "call_id": "search_1",
2656                    "status": "completed",
2657                    "arguments": {"paths": ["crm"]}
2658                }),
2659            ),
2660            (
2661                ProviderTranscriptAuthor::ToolResult,
2662                json!({
2663                    "type": "tool_search_output",
2664                    "id": "tso_1",
2665                    "execution": "server",
2666                    "call_id": "search_1",
2667                    "status": "completed",
2668                    "tools": [{"type": "function", "name": "list_open_orders"}]
2669                }),
2670            ),
2671            (
2672                ProviderTranscriptAuthor::Model,
2673                json!({
2674                    "type": "function_call",
2675                    "call_id": "call_abc123",
2676                    "name": "list_open_orders",
2677                    "arguments": "{\"customer_id\":\"CUST-12345\"}"
2678                }),
2679            ),
2680        ]
2681        .into_iter()
2682        .map(|(author, payload)| {
2683            ProviderTranscriptItem::try_from_payload(
2684                ProviderFamily::OpenAi,
2685                ProviderProtocol::OpenAiResponsesV1,
2686                ProviderTranscriptOrigin::Provider,
2687                author,
2688                payload,
2689            )
2690            .unwrap()
2691        })
2692        .collect()
2693    }
2694
2695    fn anthropic_items() -> Vec<ProviderTranscriptItem> {
2696        [
2697            (
2698                ProviderTranscriptAuthor::Model,
2699                json!({"type":"text","text":"I will search."}),
2700            ),
2701            (
2702                ProviderTranscriptAuthor::Model,
2703                json!({
2704                    "type":"server_tool_use",
2705                    "id":"srvtoolu_01ABC123",
2706                    "name":"tool_search_tool_regex",
2707                    "input":{"pattern":"weather"}
2708                }),
2709            ),
2710            (
2711                ProviderTranscriptAuthor::ToolResult,
2712                json!({
2713                    "type":"tool_search_tool_result",
2714                    "tool_use_id":"srvtoolu_01ABC123",
2715                    "content":{
2716                        "type":"tool_search_tool_search_result",
2717                        "tool_references":[
2718                            {"type":"tool_reference","tool_name":"get_weather"}
2719                        ]
2720                    }
2721                }),
2722            ),
2723            (
2724                ProviderTranscriptAuthor::Model,
2725                json!({
2726                    "type":"tool_use",
2727                    "id":"toolu_01XYZ789",
2728                    "name":"get_weather",
2729                    "input":{"location":"San Francisco"}
2730                }),
2731            ),
2732        ]
2733        .into_iter()
2734        .map(|(author, payload)| {
2735            ProviderTranscriptItem::try_from_payload(
2736                ProviderFamily::Anthropic,
2737                ProviderProtocol::AnthropicMessages2023_06_01,
2738                ProviderTranscriptOrigin::Provider,
2739                author,
2740                payload,
2741            )
2742            .unwrap()
2743        })
2744        .collect()
2745    }
2746
2747    fn assert_payload_rejected_without_leak(
2748        family: ProviderFamily,
2749        protocol: ProviderProtocol,
2750        origin: ProviderTranscriptOrigin,
2751        author: ProviderTranscriptAuthor,
2752        payload: Value,
2753    ) {
2754        let error =
2755            ProviderTranscriptItem::try_from_payload(family, protocol, origin, author, payload)
2756                .expect_err("payload must fail closed");
2757        let diagnostic = format!("{error:?} {error}");
2758        assert!(!diagnostic.contains("UNKNOWN_FIELD_SENTINEL"));
2759    }
2760
2761    fn assert_unknown_top_level_rejected(item: &ProviderTranscriptItem) {
2762        let mut payload = item.payload().clone();
2763        payload
2764            .as_object_mut()
2765            .unwrap()
2766            .insert("unknown".to_string(), json!("UNKNOWN_FIELD_SENTINEL"));
2767        assert_payload_rejected_without_leak(
2768            item.family(),
2769            item.protocol(),
2770            item.origin(),
2771            item.author(),
2772            payload,
2773        );
2774    }
2775
2776    #[test]
2777    fn openai_search_items_round_trip_in_exact_order() {
2778        let items = openai_output_items();
2779        let encoded = serde_json::to_string(&items).unwrap();
2780        let decoded: Vec<ProviderTranscriptItem> = serde_json::from_str(&encoded).unwrap();
2781        assert_eq!(decoded, items);
2782        assert_eq!(
2783            decoded
2784                .iter()
2785                .map(ProviderTranscriptItem::kind)
2786                .collect::<Vec<_>>(),
2787            vec![
2788                ProviderTranscriptItemKind::OpenAiToolSearchCall,
2789                ProviderTranscriptItemKind::OpenAiToolSearchOutput,
2790                ProviderTranscriptItemKind::OpenAiFunctionCall,
2791            ]
2792        );
2793    }
2794
2795    #[test]
2796    fn openai_provider_output_preserves_official_nullable_fields() {
2797        let payloads = [
2798            (
2799                ProviderTranscriptAuthor::Model,
2800                json!({
2801                    "type":"reasoning","id":"rs_nullable","summary":[],
2802                    "agent":null,"content":null,"encrypted_content":null,"status":null
2803                }),
2804            ),
2805            (
2806                ProviderTranscriptAuthor::Model,
2807                json!({
2808                    "type":"message","id":"msg_nullable","role":"assistant",
2809                    "status":"completed","agent":null,"phase":null,
2810                    "content":[{
2811                        "type":"output_text","text":"Searching",
2812                        "annotations":[],"logprobs":null
2813                    }]
2814                }),
2815            ),
2816            (
2817                ProviderTranscriptAuthor::Model,
2818                json!({
2819                    "type":"tool_search_call","id":"tsc_nullable","execution":"server",
2820                    "call_id":null,"status":"completed","arguments":{"query":"orders"},
2821                    "agent":null,"created_by":null
2822                }),
2823            ),
2824            (
2825                ProviderTranscriptAuthor::ToolResult,
2826                json!({
2827                    "type":"tool_search_output","id":"tso_nullable","execution":"server",
2828                    "call_id":null,"status":"completed","agent":null,"created_by":null,
2829                    "tools":[{"type":"function","name":"get_orders"}]
2830                }),
2831            ),
2832            (
2833                ProviderTranscriptAuthor::Model,
2834                json!({
2835                    "type":"function_call","id":null,"call_id":"call_nullable",
2836                    "name":"get_orders","arguments":"{}","agent":null,"caller":null,
2837                    "namespace":null,"status":null,"created_by":null
2838                }),
2839            ),
2840        ];
2841        let expected = payloads
2842            .iter()
2843            .map(|(_, payload)| payload.clone())
2844            .collect::<Vec<_>>();
2845        let items = payloads
2846            .into_iter()
2847            .map(|(author, payload)| {
2848                ProviderTranscriptItem::try_from_payload(
2849                    ProviderFamily::OpenAi,
2850                    ProviderProtocol::OpenAiResponsesV1,
2851                    ProviderTranscriptOrigin::Provider,
2852                    author,
2853                    payload,
2854                )
2855                .unwrap()
2856            })
2857            .collect::<Vec<_>>();
2858
2859        ProviderTranscriptGroup::validate_items(&items).unwrap();
2860        assert_eq!(
2861            items
2862                .iter()
2863                .map(|item| item.payload().clone())
2864                .collect::<Vec<_>>(),
2865            expected
2866        );
2867    }
2868
2869    #[test]
2870    fn anthropic_reference_chain_round_trips_without_schema_expansion() {
2871        let items = anthropic_items();
2872        let encoded = serde_json::to_value(&items).unwrap();
2873        let decoded: Vec<ProviderTranscriptItem> = serde_json::from_value(encoded).unwrap();
2874        assert_eq!(decoded, items);
2875        assert_eq!(
2876            decoded[2].payload()["content"]["tool_references"][0],
2877            json!({"type":"tool_reference","tool_name":"get_weather"})
2878        );
2879    }
2880
2881    #[test]
2882    fn client_output_and_additional_tools_are_position_safe_variants() {
2883        let client = ProviderTranscriptItem::try_from_payload(
2884            ProviderFamily::OpenAi,
2885            ProviderProtocol::OpenAiResponsesV1,
2886            ProviderTranscriptOrigin::HostToolSearch,
2887            ProviderTranscriptAuthor::ToolResult,
2888            json!({
2889                "type":"tool_search_output",
2890                "execution":"client",
2891                "call_id":"call_abc123",
2892                "status":"completed",
2893                "tools":[]
2894            }),
2895        )
2896        .unwrap();
2897        let additional = ProviderTranscriptItem::try_from_payload(
2898            ProviderFamily::OpenAi,
2899            ProviderProtocol::OpenAiResponsesV1,
2900            ProviderTranscriptOrigin::DeveloperContext,
2901            ProviderTranscriptAuthor::Host,
2902            json!({"type":"additional_tools","role":"developer","tools":[]}),
2903        )
2904        .unwrap();
2905        assert_eq!(
2906            client.kind(),
2907            ProviderTranscriptItemKind::OpenAiToolSearchOutput
2908        );
2909        assert_eq!(
2910            additional.kind(),
2911            ProviderTranscriptItemKind::OpenAiAdditionalTools
2912        );
2913        ProviderTranscriptGroup::validate_items(std::slice::from_ref(&client))
2914            .expect("a client output is a standalone host-owned continuation group");
2915    }
2916
2917    #[test]
2918    fn openai_loaded_names_survive_resume_and_come_only_from_search_output() {
2919        let mut session = Session::new("client-loaded-resume", "gpt-5.6");
2920        activate_openai(&mut session);
2921        let assistant = Message::assistant("", None);
2922        let anchor = assistant.id.clone();
2923        session.add_message(assistant);
2924        let call = ProviderTranscriptItem::try_from_payload(
2925            ProviderFamily::OpenAi,
2926            ProviderProtocol::OpenAiResponsesV1,
2927            ProviderTranscriptOrigin::Provider,
2928            ProviderTranscriptAuthor::Model,
2929            json!({
2930                "type":"tool_search_call","id":"tsc_resume","execution":"client",
2931                "call_id":"search_resume","status":"completed",
2932                "arguments":{"query":"files"}
2933            }),
2934        )
2935        .unwrap();
2936        session
2937            .append_provider_transcript_group(&anchor, None, vec![call])
2938            .unwrap();
2939        let output = ProviderTranscriptItem::try_from_payload(
2940            ProviderFamily::OpenAi,
2941            ProviderProtocol::OpenAiResponsesV1,
2942            ProviderTranscriptOrigin::HostToolSearch,
2943            ProviderTranscriptAuthor::ToolResult,
2944            json!({
2945                "type":"tool_search_output","execution":"client",
2946                "call_id":"search_resume","status":"completed","tools":[{
2947                    "type":"function","name":"Glob","description":"Find files",
2948                    "parameters":{"type":"object"},"strict":false,"defer_loading":true
2949                }]
2950            }),
2951        )
2952        .unwrap();
2953        session
2954            .append_provider_transcript_group(&anchor, None, vec![output])
2955            .unwrap();
2956
2957        let names =
2958            validated_openai_loaded_tool_names(replayable_openai(&session), ProviderFamily::OpenAi);
2959        assert_eq!(names, vec!["Glob"]);
2960
2961        let resumed: Session =
2962            serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
2963        let resumed_names =
2964            validated_openai_loaded_tool_names(replayable_openai(&resumed), ProviderFamily::OpenAi);
2965        assert_eq!(resumed_names, vec!["Glob"]);
2966
2967        let mut hosted = Session::new("hosted-loaded", "gpt-5.6");
2968        activate_openai(&mut hosted);
2969        let assistant = Message::assistant("normalized", None);
2970        let anchor = assistant.id.clone();
2971        hosted.add_message(assistant);
2972        hosted
2973            .append_provider_transcript_group(&anchor, None, openai_output_items())
2974            .unwrap();
2975        assert_eq!(
2976            validated_openai_loaded_tool_names(replayable_openai(&hosted), ProviderFamily::OpenAi,),
2977            vec!["list_open_orders"],
2978            "the following ordinary function_call must not add loading state"
2979        );
2980    }
2981
2982    #[test]
2983    fn openai_loaded_definitions_distinguish_provider_output_from_host_input() {
2984        for definition in [
2985            json!({"type":"function","name":"get_weather"}),
2986            json!({
2987                "type":"function","name":"get_weather",
2988                "parameters":null,"strict":null,"allowed_callers":null,
2989                "defer_loading":null,"description":null,"output_schema":null
2990            }),
2991        ] {
2992            ProviderTranscriptItem::try_from_payload(
2993                ProviderFamily::OpenAi,
2994                ProviderProtocol::OpenAiResponsesV1,
2995                ProviderTranscriptOrigin::Provider,
2996                ProviderTranscriptAuthor::ToolResult,
2997                json!({
2998                    "type":"tool_search_output","id":"tso_provider","execution":"server",
2999                    "call_id":"search_provider","status":"completed","tools":[definition]
3000                }),
3001            )
3002            .expect("provider output permits omitted or nullable optional definition fields");
3003        }
3004
3005        let host_positions = |definition: Value| {
3006            [
3007                (
3008                    ProviderTranscriptOrigin::HostToolSearch,
3009                    ProviderTranscriptAuthor::ToolResult,
3010                    json!({
3011                        "type":"tool_search_output","execution":"client",
3012                        "call_id":"search_host","status":"completed",
3013                        "tools":[definition.clone()]
3014                    }),
3015                ),
3016                (
3017                    ProviderTranscriptOrigin::DeveloperContext,
3018                    ProviderTranscriptAuthor::Host,
3019                    json!({
3020                        "type":"additional_tools","role":"developer","tools":[definition]
3021                    }),
3022                ),
3023            ]
3024        };
3025
3026        for definition in [
3027            json!({
3028                "type":"function","name":"workflow_run",
3029                "parameters":null,"strict":null
3030            }),
3031            json!({
3032                "type":"function","name":"workflow_run",
3033                "parameters":{"type":"object"},"strict":false
3034            }),
3035        ] {
3036            for (origin, author, payload) in host_positions(definition) {
3037                ProviderTranscriptItem::try_from_payload(
3038                    ProviderFamily::OpenAi,
3039                    ProviderProtocol::OpenAiResponsesV1,
3040                    origin,
3041                    author,
3042                    payload,
3043                )
3044                .expect("host input requires both keys and permits nullable values");
3045            }
3046        }
3047
3048        for definition in [
3049            json!({"type":"function","name":"workflow_run","strict":null}),
3050            json!({
3051                "type":"function","name":"workflow_run",
3052                "parameters":{"type":"object"}
3053            }),
3054            json!({
3055                "type":"function","name":"workflow_run",
3056                "parameters":[],"strict":null
3057            }),
3058            json!({
3059                "type":"function","name":"workflow_run",
3060                "parameters":null,"strict":"false"
3061            }),
3062        ] {
3063            for (origin, author, payload) in host_positions(definition) {
3064                assert!(ProviderTranscriptItem::try_from_payload(
3065                    ProviderFamily::OpenAi,
3066                    ProviderProtocol::OpenAiResponsesV1,
3067                    origin,
3068                    author,
3069                    payload,
3070                )
3071                .is_err());
3072            }
3073        }
3074
3075        let scoped = ProviderTranscriptItem::try_from_payload(
3076            ProviderFamily::OpenAi,
3077            ProviderProtocol::OpenAiResponsesV1,
3078            ProviderTranscriptOrigin::HostToolSearch,
3079            ProviderTranscriptAuthor::ToolResult,
3080            json!({
3081                "type":"tool_search_output","execution":"client","call_id":"search_scoped",
3082                "status":"completed","tools":[{
3083                    "type":"function","name":"load_skill","defer_loading":true,
3084                    "strict":false,
3085                    "parameters":{
3086                        "type":"object",
3087                        "properties":{"skill_id":{"type":"string","const":"skill:pinned@rev-7"}},
3088                        "required":["skill_id"],"additionalProperties":false
3089                    }
3090                }]
3091            }),
3092        )
3093        .expect("host input with a complete pinned parameter schema is valid");
3094        let encoded = serde_json::to_value(&scoped).unwrap();
3095        assert_eq!(
3096            serde_json::from_value::<ProviderTranscriptItem>(encoded).unwrap(),
3097            scoped
3098        );
3099
3100        for nested in [
3101            json!({"type":"function","name":"lookup"}),
3102            json!({"type":"function","name":"lookup","parameters":null,"strict":null}),
3103        ] {
3104            ProviderTranscriptItem::try_from_payload(
3105                ProviderFamily::OpenAi,
3106                ProviderProtocol::OpenAiResponsesV1,
3107                ProviderTranscriptOrigin::DeveloperContext,
3108                ProviderTranscriptAuthor::Host,
3109                json!({
3110                    "type":"additional_tools","role":"developer","tools":[{
3111                        "type":"namespace","name":"crm","description":"CRM tools","tools":[nested]
3112                    }]
3113                }),
3114            )
3115            .expect("namespace member parameters remain optional and nullable");
3116        }
3117    }
3118
3119    #[test]
3120    fn anthropic_builtin_search_inputs_are_name_typed_and_bounded() {
3121        let item = |name: &str, input: Value| {
3122            ProviderTranscriptItem::try_from_payload(
3123                ProviderFamily::Anthropic,
3124                ProviderProtocol::AnthropicMessages2023_06_01,
3125                ProviderTranscriptOrigin::Provider,
3126                ProviderTranscriptAuthor::Model,
3127                json!({
3128                    "type":"server_tool_use","id":"srvtoolu_boundary","name":name,"input":input
3129                }),
3130            )
3131        };
3132
3133        for input in [
3134            json!({"pattern":"x".repeat(200),"limit":1}),
3135            json!({"pattern":"weather","limit":10_000}),
3136        ] {
3137            item("tool_search_tool_regex", input).expect("regex boundary should be valid");
3138        }
3139        for input in [
3140            json!({"query":"x".repeat(500),"limit":1}),
3141            json!({"query":"weather tools","limit":10_000}),
3142        ] {
3143            item("tool_search_tool_bm25", input).expect("BM25 boundary should be valid");
3144        }
3145
3146        let invalid = [
3147            ("tool_search_tool_regex", json!({})),
3148            ("tool_search_tool_regex", json!({"pattern":17})),
3149            ("tool_search_tool_regex", json!({"pattern":"x".repeat(201)})),
3150            ("tool_search_tool_regex", json!({"query":"wrong key"})),
3151            (
3152                "tool_search_tool_regex",
3153                json!({"pattern":"ok","credential":"SEARCH_INPUT_SENTINEL"}),
3154            ),
3155            ("tool_search_tool_bm25", json!({})),
3156            ("tool_search_tool_bm25", json!({"query":17})),
3157            ("tool_search_tool_bm25", json!({"query":"x".repeat(501)})),
3158            ("tool_search_tool_bm25", json!({"pattern":"wrong key"})),
3159            ("tool_search_tool_bm25", json!({"query":"ok","limit":0})),
3160            (
3161                "tool_search_tool_bm25",
3162                json!({"query":"ok","limit":10_001}),
3163            ),
3164            ("tool_search_tool_bm25", json!({"query":"ok","limit":1.5})),
3165            ("tool_search_tool_bm25", json!({"query":"ok","limit":"1"})),
3166        ];
3167        for (name, input) in invalid {
3168            let error = item(name, input).expect_err("invalid search input must fail closed");
3169            let diagnostic = format!("{error:?} {error}");
3170            assert!(!diagnostic.contains("SEARCH_INPUT_SENTINEL"));
3171        }
3172    }
3173
3174    #[test]
3175    fn unsupported_or_malformed_items_fail_closed() {
3176        let unsupported = ProviderTranscriptItem::try_from_payload(
3177            ProviderFamily::OpenAi,
3178            ProviderProtocol::OpenAiResponsesV1,
3179            ProviderTranscriptOrigin::Provider,
3180            ProviderTranscriptAuthor::Model,
3181            json!({"type":"arbitrary_json","secret":"do-not-replay"}),
3182        );
3183        assert!(matches!(
3184            unsupported,
3185            Err(ProviderTranscriptError::UnsupportedItemType)
3186        ));
3187
3188        let malformed = ProviderTranscriptItem::try_from_payload(
3189            ProviderFamily::Anthropic,
3190            ProviderProtocol::AnthropicMessages2023_06_01,
3191            ProviderTranscriptOrigin::Provider,
3192            ProviderTranscriptAuthor::ToolResult,
3193            json!({
3194                "type":"tool_search_tool_result",
3195                "tool_use_id":"srvtoolu_1",
3196                "content":{"type":"tool_search_tool_search_result","tool_references":[
3197                    {"type":"not_a_reference","tool_name":"danger"}
3198                ]}
3199            }),
3200        );
3201        assert!(malformed.is_err());
3202
3203        let mut reversed = openai_output_items();
3204        reversed.swap(0, 1);
3205        let group = test_group("anchor", reversed);
3206        assert_eq!(
3207            group.unwrap_err(),
3208            ProviderTranscriptError::InvalidGroupOrder
3209        );
3210    }
3211
3212    #[test]
3213    fn nested_capability_and_content_shapes_fail_closed_without_payload_leaks() {
3214        let cases = [
3215            ProviderTranscriptItem::try_from_payload(
3216                ProviderFamily::OpenAi,
3217                ProviderProtocol::OpenAiResponsesV1,
3218                ProviderTranscriptOrigin::Provider,
3219                ProviderTranscriptAuthor::Model,
3220                json!({"type":"reasoning","secret":"REASONING_SENTINEL"}),
3221            ),
3222            ProviderTranscriptItem::try_from_payload(
3223                ProviderFamily::OpenAi,
3224                ProviderProtocol::OpenAiResponsesV1,
3225                ProviderTranscriptOrigin::Provider,
3226                ProviderTranscriptAuthor::ToolResult,
3227                json!({
3228                    "type":"tool_search_output","id":"tso_invalid","execution":"server",
3229                    "call_id":"search_invalid",
3230                    "status":"completed","tools":[42],"secret":"TOOLS_SENTINEL"
3231                }),
3232            ),
3233            ProviderTranscriptItem::try_from_payload(
3234                ProviderFamily::OpenAi,
3235                ProviderProtocol::OpenAiResponsesV1,
3236                ProviderTranscriptOrigin::DeveloperContext,
3237                ProviderTranscriptAuthor::Host,
3238                json!({
3239                    "type":"additional_tools","role":"developer",
3240                    "tools":[{"type":"future_capability","secret":"CAP_SENTINEL"}]
3241                }),
3242            ),
3243            ProviderTranscriptItem::try_from_payload(
3244                ProviderFamily::OpenAi,
3245                ProviderProtocol::OpenAiResponsesV1,
3246                ProviderTranscriptOrigin::Provider,
3247                ProviderTranscriptAuthor::Model,
3248                json!({
3249                    "type":"message","id":"msg_invalid","role":"assistant","status":"completed",
3250                    "content":[{"type":"future_content","secret":"CONTENT_SENTINEL"}]
3251                }),
3252            ),
3253            ProviderTranscriptItem::try_from_payload(
3254                ProviderFamily::Anthropic,
3255                ProviderProtocol::AnthropicMessages2023_06_01,
3256                ProviderTranscriptOrigin::Provider,
3257                ProviderTranscriptAuthor::Model,
3258                json!({"type":"thinking","thinking":"private","secret":"THINK_SENTINEL"}),
3259            ),
3260        ];
3261        for result in cases {
3262            let error = result.expect_err("unsupported nested shapes must fail closed");
3263            let diagnostic = format!("{error:?} {error}");
3264            for sentinel in [
3265                "REASONING_SENTINEL",
3266                "TOOLS_SENTINEL",
3267                "CAP_SENTINEL",
3268                "CONTENT_SENTINEL",
3269                "THINK_SENTINEL",
3270            ] {
3271                assert!(!diagnostic.contains(sentinel));
3272            }
3273        }
3274
3275        let reasoning = ProviderTranscriptItem::try_from_payload(
3276            ProviderFamily::OpenAi,
3277            ProviderProtocol::OpenAiResponsesV1,
3278            ProviderTranscriptOrigin::Provider,
3279            ProviderTranscriptAuthor::Model,
3280            json!({
3281                "id":"rs_1","type":"reasoning","status":"completed",
3282                "summary":[{"type":"summary_text","text":"bounded summary"}],
3283                "encrypted_content":"opaque"
3284            }),
3285        )
3286        .unwrap();
3287        assert_eq!(
3288            reasoning.kind(),
3289            ProviderTranscriptItemKind::OpenAiReasoning
3290        );
3291
3292        let thinking = ProviderTranscriptItem::try_from_payload(
3293            ProviderFamily::Anthropic,
3294            ProviderProtocol::AnthropicMessages2023_06_01,
3295            ProviderTranscriptOrigin::Provider,
3296            ProviderTranscriptAuthor::Model,
3297            json!({"type":"thinking","thinking":"private","signature":"signed"}),
3298        )
3299        .unwrap();
3300        assert_eq!(
3301            thinking.kind(),
3302            ProviderTranscriptItemKind::AnthropicThinking
3303        );
3304    }
3305
3306    #[test]
3307    fn every_supported_payload_layer_rejects_unknown_fields() {
3308        let mut baselines = openai_output_items();
3309        baselines.extend(anthropic_items());
3310        baselines.extend([
3311            ProviderTranscriptItem::try_from_payload(
3312                ProviderFamily::OpenAi,
3313                ProviderProtocol::OpenAiResponsesV1,
3314                ProviderTranscriptOrigin::Provider,
3315                ProviderTranscriptAuthor::Model,
3316                json!({
3317                    "type":"message","id":"msg_1","role":"assistant","status":"completed",
3318                    "content":[{"type":"output_text","text":"done","annotations":[]}]
3319                }),
3320            )
3321            .unwrap(),
3322            ProviderTranscriptItem::try_from_payload(
3323                ProviderFamily::OpenAi,
3324                ProviderProtocol::OpenAiResponsesV1,
3325                ProviderTranscriptOrigin::Provider,
3326                ProviderTranscriptAuthor::Model,
3327                json!({
3328                    "type":"reasoning","id":"rs_1","status":"completed",
3329                    "summary":[{"type":"summary_text","text":"safe"}]
3330                }),
3331            )
3332            .unwrap(),
3333            ProviderTranscriptItem::try_from_payload(
3334                ProviderFamily::OpenAi,
3335                ProviderProtocol::OpenAiResponsesV1,
3336                ProviderTranscriptOrigin::HostToolSearch,
3337                ProviderTranscriptAuthor::ToolResult,
3338                json!({
3339                    "type":"tool_search_output","execution":"client","call_id":"search_client",
3340                    "status":"completed","tools":[]
3341                }),
3342            )
3343            .unwrap(),
3344            ProviderTranscriptItem::try_from_payload(
3345                ProviderFamily::OpenAi,
3346                ProviderProtocol::OpenAiResponsesV1,
3347                ProviderTranscriptOrigin::DeveloperContext,
3348                ProviderTranscriptAuthor::Host,
3349                json!({"type":"additional_tools","role":"developer","tools":[]}),
3350            )
3351            .unwrap(),
3352            ProviderTranscriptItem::try_from_payload(
3353                ProviderFamily::Anthropic,
3354                ProviderProtocol::AnthropicMessages2023_06_01,
3355                ProviderTranscriptOrigin::Provider,
3356                ProviderTranscriptAuthor::Model,
3357                json!({"type":"thinking","thinking":"private","signature":"opaque"}),
3358            )
3359            .unwrap(),
3360            ProviderTranscriptItem::try_from_payload(
3361                ProviderFamily::Anthropic,
3362                ProviderProtocol::AnthropicMessages2023_06_01,
3363                ProviderTranscriptOrigin::Provider,
3364                ProviderTranscriptAuthor::Model,
3365                json!({"type":"redacted_thinking","data":"opaque"}),
3366            )
3367            .unwrap(),
3368            ProviderTranscriptItem::try_from_payload(
3369                ProviderFamily::Anthropic,
3370                ProviderProtocol::AnthropicMessages2023_06_01,
3371                ProviderTranscriptOrigin::HostToolSearch,
3372                ProviderTranscriptAuthor::ToolResult,
3373                json!({
3374                    "type":"tool_result","tool_use_id":"toolu_search","is_error":false,
3375                    "content":[{"type":"tool_reference","tool_name":"get_weather"}]
3376                }),
3377            )
3378            .unwrap(),
3379        ]);
3380        for baseline in &baselines {
3381            assert_unknown_top_level_rejected(baseline);
3382        }
3383
3384        assert_payload_rejected_without_leak(
3385            ProviderFamily::OpenAi,
3386            ProviderProtocol::OpenAiResponsesV1,
3387            ProviderTranscriptOrigin::Provider,
3388            ProviderTranscriptAuthor::Model,
3389            json!({
3390                "type":"message","id":"msg_nested","role":"assistant","status":"completed",
3391                "content":[{
3392                    "type":"output_text","text":"safe","annotations":[],
3393                    "unknown":"UNKNOWN_FIELD_SENTINEL"
3394                }]
3395            }),
3396        );
3397        assert_payload_rejected_without_leak(
3398            ProviderFamily::OpenAi,
3399            ProviderProtocol::OpenAiResponsesV1,
3400            ProviderTranscriptOrigin::Provider,
3401            ProviderTranscriptAuthor::Model,
3402            json!({
3403                "type":"message","id":"msg_annotation","role":"assistant","status":"completed",
3404                "content":[{
3405                    "type":"output_text","text":"safe","annotations":[{
3406                        "type":"file_citation","file_id":"file_1","filename":"safe.txt","index":0,
3407                        "unknown":"UNKNOWN_FIELD_SENTINEL"
3408                    }]
3409                }]
3410            }),
3411        );
3412        assert_payload_rejected_without_leak(
3413            ProviderFamily::OpenAi,
3414            ProviderProtocol::OpenAiResponsesV1,
3415            ProviderTranscriptOrigin::Provider,
3416            ProviderTranscriptAuthor::Model,
3417            json!({
3418                "type":"message","id":"msg_logprob","role":"assistant","status":"completed",
3419                "content":[{
3420                    "type":"output_text","text":"safe","annotations":[],"logprobs":[{
3421                        "token":"safe","bytes":[115],"logprob":-0.1,"top_logprobs":[],
3422                        "unknown":"UNKNOWN_FIELD_SENTINEL"
3423                    }]
3424                }]
3425            }),
3426        );
3427        assert_payload_rejected_without_leak(
3428            ProviderFamily::OpenAi,
3429            ProviderProtocol::OpenAiResponsesV1,
3430            ProviderTranscriptOrigin::Provider,
3431            ProviderTranscriptAuthor::Model,
3432            json!({
3433                "type":"reasoning","id":"rs_nested","summary":[{
3434                    "type":"summary_text","text":"safe","unknown":"UNKNOWN_FIELD_SENTINEL"
3435                }]
3436            }),
3437        );
3438        assert_payload_rejected_without_leak(
3439            ProviderFamily::OpenAi,
3440            ProviderProtocol::OpenAiResponsesV1,
3441            ProviderTranscriptOrigin::Provider,
3442            ProviderTranscriptAuthor::ToolResult,
3443            json!({
3444                "type":"tool_search_output","id":"tso_nested","execution":"server",
3445                "call_id":"search_nested","status":"completed","tools":[{
3446                    "type":"function","name":"safe_tool","unknown":"UNKNOWN_FIELD_SENTINEL"
3447                }]
3448            }),
3449        );
3450        assert_payload_rejected_without_leak(
3451            ProviderFamily::OpenAi,
3452            ProviderProtocol::OpenAiResponsesV1,
3453            ProviderTranscriptOrigin::Provider,
3454            ProviderTranscriptAuthor::Model,
3455            json!({
3456                "type":"tool_search_call","id":"tsc_agent","execution":"client",
3457                "call_id":"search_agent","status":"completed","arguments":{},
3458                "agent":{"agent_name":"safe","unknown":"UNKNOWN_FIELD_SENTINEL"}
3459            }),
3460        );
3461        assert_payload_rejected_without_leak(
3462            ProviderFamily::Anthropic,
3463            ProviderProtocol::AnthropicMessages2023_06_01,
3464            ProviderTranscriptOrigin::Provider,
3465            ProviderTranscriptAuthor::Model,
3466            json!({
3467                "type":"text","text":"safe","citations":[{
3468                    "type":"web_search_result_location","cited_text":"safe",
3469                    "encrypted_index":"opaque","title":"safe","url":"https://example.invalid",
3470                    "unknown":"UNKNOWN_FIELD_SENTINEL"
3471                }]
3472            }),
3473        );
3474        assert_payload_rejected_without_leak(
3475            ProviderFamily::Anthropic,
3476            ProviderProtocol::AnthropicMessages2023_06_01,
3477            ProviderTranscriptOrigin::Provider,
3478            ProviderTranscriptAuthor::Model,
3479            json!({
3480                "type":"tool_use","id":"toolu_caller","name":"safe_tool","input":{},
3481                "caller":{"type":"direct","unknown":"UNKNOWN_FIELD_SENTINEL"}
3482            }),
3483        );
3484        assert_payload_rejected_without_leak(
3485            ProviderFamily::Anthropic,
3486            ProviderProtocol::AnthropicMessages2023_06_01,
3487            ProviderTranscriptOrigin::Provider,
3488            ProviderTranscriptAuthor::ToolResult,
3489            json!({
3490                "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3491                "content":{
3492                    "type":"tool_search_tool_search_result","tool_references":[
3493                        {"type":"tool_reference","tool_name":"safe_tool"}
3494                    ],
3495                    "unknown":"UNKNOWN_FIELD_SENTINEL"
3496                }
3497            }),
3498        );
3499        assert_payload_rejected_without_leak(
3500            ProviderFamily::Anthropic,
3501            ProviderProtocol::AnthropicMessages2023_06_01,
3502            ProviderTranscriptOrigin::Provider,
3503            ProviderTranscriptAuthor::ToolResult,
3504            json!({
3505                "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3506                "content":{
3507                    "type":"tool_search_tool_search_result","tool_references":[{
3508                        "type":"tool_reference","tool_name":"safe_tool",
3509                        "unknown":"UNKNOWN_FIELD_SENTINEL"
3510                    }]
3511                }
3512            }),
3513        );
3514        assert_payload_rejected_without_leak(
3515            ProviderFamily::Anthropic,
3516            ProviderProtocol::AnthropicMessages2023_06_01,
3517            ProviderTranscriptOrigin::Provider,
3518            ProviderTranscriptAuthor::ToolResult,
3519            json!({
3520                "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3521                "content":{
3522                    "type":"tool_search_tool_result_error","error_code":"unavailable",
3523                    "error_message":null,"unknown":"UNKNOWN_FIELD_SENTINEL"
3524                }
3525            }),
3526        );
3527    }
3528
3529    #[test]
3530    fn hosted_discovery_chains_reject_dangling_reordered_and_mismatched_uses() {
3531        let openai = openai_output_items();
3532        let mut reordered = openai.clone();
3533        reordered.swap(1, 2);
3534        assert_eq!(
3535            test_group("openai", reordered).unwrap_err(),
3536            ProviderTranscriptError::InvalidGroupOrder
3537        );
3538        assert_eq!(
3539            test_group("openai", vec![openai[0].clone()]).unwrap_err(),
3540            ProviderTranscriptError::InvalidGroupOrder
3541        );
3542        let mismatched_call = ProviderTranscriptItem::try_from_payload(
3543            ProviderFamily::OpenAi,
3544            ProviderProtocol::OpenAiResponsesV1,
3545            ProviderTranscriptOrigin::Provider,
3546            ProviderTranscriptAuthor::Model,
3547            json!({
3548                "type":"function_call","call_id":"call_other",
3549                "name":"not_loaded","arguments":"{}"
3550            }),
3551        )
3552        .unwrap();
3553        assert_eq!(
3554            test_group(
3555                "openai",
3556                vec![openai[0].clone(), openai[1].clone(), mismatched_call],
3557            )
3558            .unwrap_err(),
3559            ProviderTranscriptError::InvalidGroupOrder
3560        );
3561
3562        let hosted_item = |author, payload| {
3563            ProviderTranscriptItem::try_from_payload(
3564                ProviderFamily::OpenAi,
3565                ProviderProtocol::OpenAiResponsesV1,
3566                ProviderTranscriptOrigin::Provider,
3567                author,
3568                payload,
3569            )
3570            .unwrap()
3571        };
3572        let call_two = hosted_item(
3573            ProviderTranscriptAuthor::Model,
3574            json!({
3575                "type":"tool_search_call","id":"tsc_2","execution":"server",
3576                "call_id":"search_2","status":"completed","arguments":{"query":"support"}
3577            }),
3578        );
3579        let output_two = hosted_item(
3580            ProviderTranscriptAuthor::ToolResult,
3581            json!({
3582                "type":"tool_search_output","id":"tso_2","execution":"server",
3583                "call_id":"search_2","status":"completed",
3584                "tools":[{"type":"function","name":"list_support_cases"}]
3585            }),
3586        );
3587        let function_two = hosted_item(
3588            ProviderTranscriptAuthor::Model,
3589            json!({
3590                "type":"function_call","id":"fc_2","call_id":"function_2",
3591                "name":"list_support_cases","arguments":"{}","status":"completed"
3592            }),
3593        );
3594        let parallel = vec![
3595            openai[0].clone(),
3596            call_two.clone(),
3597            openai[1].clone(),
3598            output_two.clone(),
3599            openai[2].clone(),
3600            function_two,
3601        ];
3602        assert!(test_group("openai-parallel", parallel).is_ok());
3603
3604        let unkeyed_parallel = vec![
3605            hosted_item(
3606                ProviderTranscriptAuthor::Model,
3607                json!({
3608                    "type":"tool_search_call","id":"tsc_unkeyed_1","execution":"server",
3609                    "call_id":null,"status":"completed","arguments":{"query":"first"}
3610                }),
3611            ),
3612            hosted_item(
3613                ProviderTranscriptAuthor::Model,
3614                json!({
3615                    "type":"tool_search_call","id":"tsc_unkeyed_2","execution":"server",
3616                    "status":"completed","arguments":{"query":"second"}
3617                }),
3618            ),
3619            hosted_item(
3620                ProviderTranscriptAuthor::ToolResult,
3621                json!({
3622                    "type":"tool_search_output","id":"tso_unkeyed_1","execution":"server",
3623                    "call_id":null,"status":"completed","tools":[]
3624                }),
3625            ),
3626            hosted_item(
3627                ProviderTranscriptAuthor::ToolResult,
3628                json!({
3629                    "type":"tool_search_output","id":"tso_unkeyed_2","execution":"server",
3630                    "status":"completed","tools":[]
3631                }),
3632            ),
3633        ];
3634        assert!(test_group("openai-unkeyed-parallel", unkeyed_parallel).is_ok());
3635
3636        let mismatched_output = hosted_item(
3637            ProviderTranscriptAuthor::ToolResult,
3638            json!({
3639                "type":"tool_search_output","id":"tso_mismatch","execution":"server",
3640                "call_id":"search_missing","status":"completed","tools":[]
3641            }),
3642        );
3643        assert_eq!(
3644            test_group(
3645                "openai-mismatch",
3646                vec![openai[0].clone(), mismatched_output],
3647            )
3648            .unwrap_err(),
3649            ProviderTranscriptError::InvalidGroupOrder
3650        );
3651
3652        let duplicate_id_call = hosted_item(
3653            ProviderTranscriptAuthor::Model,
3654            json!({
3655                "type":"tool_search_call","id":"tsc_1","execution":"server",
3656                "call_id":"search_duplicate_id","status":"completed","arguments":{}
3657            }),
3658        );
3659        assert_eq!(
3660            test_group(
3661                "openai-duplicate-id",
3662                vec![openai[0].clone(), duplicate_id_call],
3663            )
3664            .unwrap_err(),
3665            ProviderTranscriptError::InvalidGroupOrder
3666        );
3667
3668        let duplicate_call_id = hosted_item(
3669            ProviderTranscriptAuthor::Model,
3670            json!({
3671                "type":"tool_search_call","id":"tsc_duplicate_call","execution":"server",
3672                "call_id":"search_1","status":"completed","arguments":{}
3673            }),
3674        );
3675        assert_eq!(
3676            test_group(
3677                "openai-duplicate-call",
3678                vec![openai[0].clone(), duplicate_call_id],
3679            )
3680            .unwrap_err(),
3681            ProviderTranscriptError::InvalidGroupOrder
3682        );
3683        assert!(ProviderTranscriptItem::try_from_payload(
3684            ProviderFamily::OpenAi,
3685            ProviderProtocol::OpenAiResponsesV1,
3686            ProviderTranscriptOrigin::Provider,
3687            ProviderTranscriptAuthor::Model,
3688            json!({
3689                "type":"tool_search_call","execution":"server","call_id":"search_missing_id",
3690                "status":"completed","arguments":{}
3691            }),
3692        )
3693        .is_err());
3694        for call_id in [json!(""), json!(7)] {
3695            assert!(ProviderTranscriptItem::try_from_payload(
3696                ProviderFamily::OpenAi,
3697                ProviderProtocol::OpenAiResponsesV1,
3698                ProviderTranscriptOrigin::Provider,
3699                ProviderTranscriptAuthor::Model,
3700                json!({
3701                    "type":"tool_search_call","id":"tsc_invalid_call","execution":"server",
3702                    "call_id":call_id,"status":"completed","arguments":{}
3703                }),
3704            )
3705            .is_err());
3706        }
3707        ProviderTranscriptItem::try_from_payload(
3708            ProviderFamily::OpenAi,
3709            ProviderProtocol::OpenAiResponsesV1,
3710            ProviderTranscriptOrigin::Provider,
3711            ProviderTranscriptAuthor::Model,
3712            json!({
3713                "type":"tool_search_call","id":"tsc_missing_call","execution":"server",
3714                "status":"completed","arguments":{}
3715            }),
3716        )
3717        .expect("the official hosted output model permits an omitted call_id");
3718
3719        for malformed_client in [
3720            json!({
3721                "type":"tool_search_call","id":"tsc_client_missing","execution":"client",
3722                "status":"completed","arguments":{}
3723            }),
3724            json!({
3725                "type":"tool_search_call","id":"tsc_client_null","execution":"client",
3726                "call_id":null,"status":"completed","arguments":{}
3727            }),
3728            json!({
3729                "type":"tool_search_call","id":"tsc_client_empty","execution":"client",
3730                "call_id":"","status":"completed","arguments":{}
3731            }),
3732            json!({
3733                "type":"tool_search_call","id":"tsc_client_typed","execution":"client",
3734                "call_id":7,"status":"completed","arguments":{}
3735            }),
3736        ] {
3737            assert!(ProviderTranscriptItem::try_from_payload(
3738                ProviderFamily::OpenAi,
3739                ProviderProtocol::OpenAiResponsesV1,
3740                ProviderTranscriptOrigin::Provider,
3741                ProviderTranscriptAuthor::Model,
3742                malformed_client,
3743            )
3744            .is_err());
3745        }
3746
3747        // Client execution intentionally stops after the call. Its host output
3748        // is committed in a later input group and may therefore stand alone.
3749        let client_call = ProviderTranscriptItem::try_from_payload(
3750            ProviderFamily::OpenAi,
3751            ProviderProtocol::OpenAiResponsesV1,
3752            ProviderTranscriptOrigin::Provider,
3753            ProviderTranscriptAuthor::Model,
3754            json!({
3755                "type":"tool_search_call","id":"tsc_client_1","execution":"client","call_id":"search_1",
3756                "status":"completed","arguments":{"query":"weather"}
3757            }),
3758        )
3759        .unwrap();
3760        assert!(test_group("client-call", vec![client_call.clone()]).is_ok());
3761        let premature_function_call = hosted_item(
3762            ProviderTranscriptAuthor::Model,
3763            json!({
3764                "type":"function_call","id":"fc_client_premature","call_id":"function_1",
3765                "name":"get_weather","arguments":"{}","status":"completed"
3766            }),
3767        );
3768        assert_eq!(
3769            test_group(
3770                "client-call-must-stop",
3771                vec![client_call.clone(), premature_function_call.clone()],
3772            )
3773            .unwrap_err(),
3774            ProviderTranscriptError::InvalidGroupOrder
3775        );
3776        assert_eq!(
3777            test_group(
3778                "function-cannot-precede-client-call",
3779                vec![premature_function_call, client_call.clone()],
3780            )
3781            .unwrap_err(),
3782            ProviderTranscriptError::InvalidGroupOrder
3783        );
3784
3785        let host_client_output = ProviderTranscriptItem::try_from_payload(
3786            ProviderFamily::OpenAi,
3787            ProviderProtocol::OpenAiResponsesV1,
3788            ProviderTranscriptOrigin::HostToolSearch,
3789            ProviderTranscriptAuthor::ToolResult,
3790            json!({
3791                "type":"tool_search_output","execution":"client",
3792                "call_id":"search_1","status":"completed","tools":[]
3793            }),
3794        )
3795        .unwrap();
3796        assert_eq!(
3797            test_group(
3798                "client-call-cannot-contain-host-output",
3799                vec![client_call.clone(), host_client_output.clone()],
3800            )
3801            .unwrap_err(),
3802            ProviderTranscriptError::InvalidGroupOrder
3803        );
3804        assert_eq!(
3805            test_group(
3806                "host-output-cannot-precede-client-call",
3807                vec![host_client_output.clone(), client_call],
3808            )
3809            .unwrap_err(),
3810            ProviderTranscriptError::InvalidGroupOrder
3811        );
3812        assert!(test_group("standalone-host-output", vec![host_client_output]).is_ok());
3813
3814        let anthropic = anthropic_items();
3815        let mut reordered = anthropic.clone();
3816        reordered.swap(2, 3);
3817        assert_eq!(
3818            test_group("anthropic", reordered).unwrap_err(),
3819            ProviderTranscriptError::InvalidGroupOrder
3820        );
3821        assert_eq!(
3822            test_group("anthropic", vec![anthropic[1].clone()]).unwrap_err(),
3823            ProviderTranscriptError::InvalidGroupOrder
3824        );
3825        let mismatched_use = ProviderTranscriptItem::try_from_payload(
3826            ProviderFamily::Anthropic,
3827            ProviderProtocol::AnthropicMessages2023_06_01,
3828            ProviderTranscriptOrigin::Provider,
3829            ProviderTranscriptAuthor::Model,
3830            json!({"type":"tool_use","id":"tool_other","name":"not_loaded","input":{}}),
3831        )
3832        .unwrap();
3833        assert_eq!(
3834            test_group(
3835                "anthropic",
3836                vec![anthropic[1].clone(), anthropic[2].clone(), mismatched_use],
3837            )
3838            .unwrap_err(),
3839            ProviderTranscriptError::InvalidGroupOrder
3840        );
3841    }
3842
3843    #[test]
3844    fn anthropic_groups_require_one_leading_thinking_block_and_unique_tool_use_ids() {
3845        let item = |author, payload| {
3846            ProviderTranscriptItem::try_from_payload(
3847                ProviderFamily::Anthropic,
3848                ProviderProtocol::AnthropicMessages2023_06_01,
3849                ProviderTranscriptOrigin::Provider,
3850                author,
3851                payload,
3852            )
3853            .unwrap()
3854        };
3855        let thinking = item(
3856            ProviderTranscriptAuthor::Model,
3857            json!({"type":"thinking","thinking":"private","signature":"signed"}),
3858        );
3859        let redacted = item(
3860            ProviderTranscriptAuthor::Model,
3861            json!({"type":"redacted_thinking","data":"opaque"}),
3862        );
3863        let base = anthropic_items();
3864
3865        let mut leading = vec![thinking.clone()];
3866        leading.extend(base.clone());
3867        assert!(test_group("anthropic-leading-thinking", leading).is_ok());
3868
3869        let mut interior = base.clone();
3870        interior.insert(1, thinking.clone());
3871        assert_eq!(
3872            test_group("anthropic-interior-thinking", interior).unwrap_err(),
3873            ProviderTranscriptError::InvalidGroupOrder
3874        );
3875
3876        let mut multiple = vec![thinking, redacted];
3877        multiple.extend(base.clone());
3878        assert_eq!(
3879            test_group("anthropic-multiple-thinking", multiple).unwrap_err(),
3880            ProviderTranscriptError::InvalidGroupOrder
3881        );
3882
3883        let duplicate_id = item(
3884            ProviderTranscriptAuthor::Model,
3885            json!({
3886                "type":"tool_use","id":"toolu_01XYZ789",
3887                "name":"get_weather","input":{}
3888            }),
3889        );
3890        let mut duplicate = base.clone();
3891        duplicate.push(duplicate_id);
3892        assert_eq!(
3893            test_group("anthropic-duplicate-tool-id", duplicate).unwrap_err(),
3894            ProviderTranscriptError::InvalidGroupOrder
3895        );
3896
3897        let server_id_collision = item(
3898            ProviderTranscriptAuthor::Model,
3899            json!({
3900                "type":"tool_use","id":"srvtoolu_01ABC123",
3901                "name":"get_weather","input":{}
3902            }),
3903        );
3904        let mut collision = base;
3905        collision.push(server_id_collision);
3906        assert_eq!(
3907            test_group("anthropic-cross-type-tool-id", collision).unwrap_err(),
3908            ProviderTranscriptError::InvalidGroupOrder
3909        );
3910    }
3911
3912    #[test]
3913    fn anthropic_parallel_search_results_pair_by_server_id() {
3914        let item = |author, payload| {
3915            ProviderTranscriptItem::try_from_payload(
3916                ProviderFamily::Anthropic,
3917                ProviderProtocol::AnthropicMessages2023_06_01,
3918                ProviderTranscriptOrigin::Provider,
3919                author,
3920                payload,
3921            )
3922            .unwrap()
3923        };
3924        let parallel = vec![
3925            item(
3926                ProviderTranscriptAuthor::Model,
3927                json!({
3928                    "type":"server_tool_use","id":"srv_1",
3929                    "name":"tool_search_tool_regex","input":{"pattern":"weather"}
3930                }),
3931            ),
3932            item(
3933                ProviderTranscriptAuthor::Model,
3934                json!({
3935                    "type":"server_tool_use","id":"srv_2",
3936                    "name":"tool_search_tool_bm25","input":{"query":"calendar"}
3937                }),
3938            ),
3939            item(
3940                ProviderTranscriptAuthor::ToolResult,
3941                json!({
3942                    "type":"tool_search_tool_result","tool_use_id":"srv_2",
3943                    "content":{"type":"tool_search_tool_search_result","tool_references":[
3944                        {"type":"tool_reference","tool_name":"get_calendar"}
3945                    ]}
3946                }),
3947            ),
3948            item(
3949                ProviderTranscriptAuthor::ToolResult,
3950                json!({
3951                    "type":"tool_search_tool_result","tool_use_id":"srv_1",
3952                    "content":{"type":"tool_search_tool_search_result","tool_references":[
3953                        {"type":"tool_reference","tool_name":"get_weather"}
3954                    ]}
3955                }),
3956            ),
3957            item(
3958                ProviderTranscriptAuthor::Model,
3959                json!({"type":"tool_use","id":"tool_1","name":"get_weather","input":{}}),
3960            ),
3961            item(
3962                ProviderTranscriptAuthor::Model,
3963                json!({"type":"tool_use","id":"tool_2","name":"get_calendar","input":{}}),
3964            ),
3965        ];
3966        assert!(test_group("anthropic-parallel", parallel.clone()).is_ok());
3967
3968        let mut result_before_call = parallel;
3969        result_before_call.swap(1, 2);
3970        assert_eq!(
3971            test_group("anthropic-result-before-call", result_before_call).unwrap_err(),
3972            ProviderTranscriptError::InvalidGroupOrder
3973        );
3974    }
3975
3976    #[test]
3977    fn stable_ids_are_canonical_and_isolated_by_provider_identity() {
3978        let payload_a: Value = serde_json::from_str(
3979            r#"{"type":"tool_search_call","id":"tsc_stable","execution":"client","call_id":"search_1","status":"completed","arguments":{"b":2,"a":1}}"#,
3980        )
3981        .unwrap();
3982        let payload_b: Value = serde_json::from_str(
3983            r#"{"arguments":{"a":1,"b":2},"status":"completed","call_id":"search_1","execution":"client","id":"tsc_stable","type":"tool_search_call"}"#,
3984        )
3985        .unwrap();
3986        let item = |family, payload| {
3987            ProviderTranscriptItem::try_from_payload(
3988                family,
3989                ProviderProtocol::OpenAiResponsesV1,
3990                ProviderTranscriptOrigin::Provider,
3991                ProviderTranscriptAuthor::Model,
3992                payload,
3993            )
3994            .unwrap()
3995        };
3996        let openai_a = item(ProviderFamily::OpenAi, payload_a);
3997        let openai_b = item(ProviderFamily::OpenAi, payload_b);
3998        let copilot = item(ProviderFamily::Copilot, openai_a.payload().clone());
3999        assert_eq!(openai_a.id(), openai_b.id());
4000        assert_ne!(openai_a.id(), copilot.id());
4001
4002        let route_a = test_boundary("openai-route-a", "openai");
4003        let route_b = test_boundary("openai-route-b", "openai");
4004        let openai_group = ProviderTranscriptGroup::new(
4005            0,
4006            0,
4007            "anchor".to_string(),
4008            None,
4009            route_a.clone(),
4010            vec![openai_a.clone()],
4011        )
4012        .unwrap();
4013        let other_route_group =
4014            ProviderTranscriptGroup::new(0, 0, "anchor".to_string(), None, route_b, vec![openai_a])
4015                .unwrap();
4016        let copilot_group =
4017            ProviderTranscriptGroup::new(0, 0, "anchor".to_string(), None, route_a, vec![copilot])
4018                .unwrap();
4019        assert_ne!(openai_group.id(), other_route_group.id());
4020        assert_ne!(openai_group.id(), copilot_group.id());
4021
4022        let mut tampered = serde_json::to_value(openai_b).unwrap();
4023        tampered["id"] = json!("pti_tampered");
4024        assert!(serde_json::from_value::<ProviderTranscriptItem>(tampered).is_err());
4025    }
4026
4027    #[test]
4028    fn session_serialization_preserves_groups_and_old_sessions_default_empty() {
4029        let mut session = Session::new("session-native", "gpt-5.6");
4030        let assistant = Message::assistant("", None);
4031        let anchor = assistant.id.clone();
4032        session.add_message(assistant);
4033        activate_openai(&mut session);
4034        session
4035            .append_provider_transcript_group(&anchor, Some("resp_123"), openai_output_items())
4036            .unwrap();
4037
4038        let encoded = serde_json::to_string(&session).unwrap();
4039        let decoded: Session = serde_json::from_str(&encoded).unwrap();
4040        assert_eq!(decoded.provider_transcript, session.provider_transcript);
4041        assert_eq!(replayable_openai(&decoded).len(), 1);
4042        let mut compressed = decoded;
4043        compressed.reset_model_context_epoch(crate::session::ModelContextResetReason::Compression);
4044        assert!(replayable_openai(&compressed).is_empty());
4045        assert_eq!(compressed.provider_transcript.groups().len(), 1);
4046
4047        let mut old = serde_json::to_value(Session::new("old", "model")).unwrap();
4048        old.as_object_mut().unwrap().remove("provider_transcript");
4049        let old: Session = serde_json::from_value(old).unwrap();
4050        assert!(old.provider_transcript.is_empty());
4051    }
4052
4053    #[test]
4054    fn persisted_state_rejects_future_epochs_and_reused_current_sequences() {
4055        let mut session = Session::new("session-native", "gpt-5.6");
4056        let assistant = Message::assistant("", None);
4057        let anchor = assistant.id.clone();
4058        session.add_message(assistant);
4059        session
4060            .append_provider_transcript_group(&anchor, None, openai_output_items())
4061            .unwrap();
4062
4063        let mut future = serde_json::to_value(&session).unwrap();
4064        future["provider_transcript"]["groups"][0]["epoch"] = json!(1);
4065        assert!(serde_json::from_value::<Session>(future).is_err());
4066
4067        let mut reused = serde_json::to_value(&session).unwrap();
4068        reused["provider_transcript"]["next_sequence"] = json!(0);
4069        assert!(serde_json::from_value::<Session>(reused).is_err());
4070
4071        let mut missing_version = serde_json::to_value(&session).unwrap();
4072        missing_version["provider_transcript"]
4073            .as_object_mut()
4074            .unwrap()
4075            .remove("schema_version");
4076        assert!(serde_json::from_value::<Session>(missing_version).is_err());
4077
4078        let mut future_version = serde_json::to_value(&session).unwrap();
4079        future_version["provider_transcript"]["schema_version"] =
4080            json!(PROVIDER_TRANSCRIPT_SCHEMA_VERSION + 1);
4081        assert!(serde_json::from_value::<Session>(future_version).is_err());
4082
4083        let explicitly_empty: ProviderTranscriptState = serde_json::from_value(json!({})).unwrap();
4084        assert!(explicitly_empty.is_empty());
4085    }
4086
4087    #[test]
4088    fn rollback_prunes_the_whole_atomic_group() {
4089        let mut session = Session::new("session-native", "claude");
4090        let assistant = Message::assistant("", None);
4091        let anchor = assistant.id.clone();
4092        session.add_message(assistant);
4093        activate_anthropic(&mut session);
4094        session
4095            .append_provider_transcript_group(&anchor, None, anthropic_items())
4096            .unwrap();
4097        session.messages.clear();
4098
4099        assert_eq!(session.prune_provider_transcript(), 1);
4100        assert!(session.provider_transcript.groups().is_empty());
4101        assert_eq!(
4102            session.provider_transcript.last_reset_reason(),
4103            Some(ProviderTranscriptResetReason::Rollback)
4104        );
4105    }
4106
4107    #[test]
4108    fn explicit_history_rewrite_invalidates_groups_even_when_anchors_survive() {
4109        let mut session = Session::new("session-native", "gpt-5.6");
4110        let assistant = Message::assistant("before edit", None);
4111        let anchor = assistant.id.clone();
4112        session.add_message(assistant);
4113        session
4114            .append_provider_transcript_group(&anchor, None, openai_output_items())
4115            .unwrap();
4116        let previous_epoch = session.provider_transcript.epoch();
4117
4118        session.messages[0].content = "after edit".to_string();
4119        session.reset_model_context_epoch(
4120            crate::session::ModelContextResetReason::ExplicitHistoryRewrite,
4121        );
4122
4123        assert_eq!(session.provider_transcript.groups().len(), 1);
4124        assert_eq!(session.provider_transcript.epoch(), previous_epoch + 1);
4125        assert!(replayable_openai(&session).is_empty());
4126        assert_eq!(
4127            session.provider_transcript.last_reset_reason(),
4128            Some(ProviderTranscriptResetReason::ExplicitHistoryRewrite)
4129        );
4130    }
4131
4132    #[test]
4133    fn durable_and_runner_native_groups_merge_append_safely() {
4134        let mut base = Session::new("session-native", "gpt-5.6");
4135        let first = Message::assistant("first", None);
4136        let first_id = first.id.clone();
4137        let second = Message::assistant("second", None);
4138        let second_id = second.id.clone();
4139        base.add_message(first);
4140        base.add_message(second);
4141        let mut durable = base.clone();
4142        durable
4143            .append_provider_transcript_group(&first_id, None, openai_output_items())
4144            .unwrap();
4145        let mut runner = base;
4146        runner
4147            .append_provider_transcript_group(&second_id, None, openai_output_items())
4148            .unwrap();
4149
4150        crate::session::append_missing_runtime_messages(&mut runner, &durable);
4151        assert_eq!(runner.provider_transcript.groups().len(), 2);
4152        assert_eq!(
4153            replayable_openai(&runner)
4154                .into_iter()
4155                .map(ProviderTranscriptGroup::anchor_message_id)
4156                .collect::<Vec<_>>(),
4157            vec![first_id.as_str(), second_id.as_str()]
4158        );
4159    }
4160
4161    #[test]
4162    fn equal_revision_merge_is_deterministic_for_same_anchor_branches() {
4163        let assistant = Message::assistant("anchor", None);
4164        let anchor = assistant.id.clone();
4165        let mut base = Session::new("session-native", "gpt-5.6");
4166        base.add_message(assistant);
4167
4168        let mut left = base.clone();
4169        left.append_provider_transcript_group(&anchor, None, openai_output_items())
4170            .unwrap();
4171        let mut right_items = openai_output_items();
4172        right_items[0] = ProviderTranscriptItem::try_from_payload(
4173            ProviderFamily::OpenAi,
4174            ProviderProtocol::OpenAiResponsesV1,
4175            ProviderTranscriptOrigin::Provider,
4176            ProviderTranscriptAuthor::Model,
4177            json!({
4178                "type":"tool_search_call","id":"tsc_support","execution":"server","call_id":"search_1",
4179                "status":"completed","arguments":{"paths":["support"]}
4180            }),
4181        )
4182        .unwrap();
4183        let mut right = base;
4184        right
4185            .append_provider_transcript_group(&anchor, None, right_items)
4186            .unwrap();
4187
4188        let left_original = left.provider_transcript.clone();
4189        let right_original = right.provider_transcript.clone();
4190        let ordered = vec![anchor];
4191        left.provider_transcript
4192            .merge_durable_prefix(&right_original, &ordered);
4193        right
4194            .provider_transcript
4195            .merge_durable_prefix(&left_original, &ordered);
4196        assert_eq!(left.provider_transcript, right.provider_transcript);
4197        assert_eq!(replayable_openai(&left).len(), 2);
4198    }
4199
4200    #[test]
4201    fn rejected_append_is_atomic_and_reset_reason_survives_new_epoch_append() {
4202        let mut state = ProviderTranscriptState::default();
4203        state
4204            .activate_route(
4205                ProviderFamily::Anthropic,
4206                ProviderProtocol::AnthropicMessages2023_06_01,
4207                &anthropic_boundary(),
4208            )
4209            .unwrap();
4210        let before = state.clone();
4211        assert_eq!(
4212            state
4213                .append_group("anchor", None, openai_output_items())
4214                .unwrap_err(),
4215            ProviderTranscriptError::InactiveProviderRoute
4216        );
4217        assert_eq!(state, before, "rejected append must not mutate state");
4218
4219        let first = Message::assistant("openai", None);
4220        let first_anchor = first.id.clone();
4221        let second = Message::assistant("anthropic", None);
4222        let second_anchor = second.id.clone();
4223        let mut session = Session::new("switch-audit", "model");
4224        session.add_message(first);
4225        session.add_message(second);
4226        session
4227            .append_provider_transcript_group(&first_anchor, None, openai_output_items())
4228            .unwrap();
4229        activate_anthropic(&mut session);
4230        session
4231            .append_provider_transcript_group(&second_anchor, None, anthropic_items())
4232            .unwrap();
4233        let round_trip: Session =
4234            serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap();
4235        assert_eq!(
4236            round_trip.provider_transcript.last_reset_reason(),
4237            Some(ProviderTranscriptResetReason::ProviderSwitch)
4238        );
4239        assert_eq!(
4240            round_trip
4241                .provider_transcript
4242                .replayable_groups(
4243                    ProviderFamily::Anthropic,
4244                    ProviderProtocol::AnthropicMessages2023_06_01,
4245                    &anthropic_boundary(),
4246                )
4247                .len(),
4248            1
4249        );
4250    }
4251
4252    #[test]
4253    fn append_merge_never_overwrites_a_newer_provider_epoch_with_more_old_groups() {
4254        let mut base = Session::new("session-native", "model");
4255        let anchors = (0..3)
4256            .map(|index| {
4257                let assistant = Message::assistant(format!("assistant {index}"), None);
4258                let anchor = assistant.id.clone();
4259                base.add_message(assistant);
4260                anchor
4261            })
4262            .collect::<Vec<_>>();
4263        let mut durable = base.clone();
4264        durable
4265            .append_provider_transcript_group(&anchors[0], None, openai_output_items())
4266            .unwrap();
4267        durable
4268            .append_provider_transcript_group(&anchors[1], None, openai_output_items())
4269            .unwrap();
4270
4271        let mut runner = base;
4272        runner
4273            .append_provider_transcript_group(&anchors[2], None, openai_output_items())
4274            .unwrap();
4275        activate_anthropic(&mut runner);
4276        let switched_epoch = runner.provider_transcript.epoch();
4277
4278        crate::session::append_missing_runtime_messages(&mut runner, &durable);
4279        assert_eq!(runner.provider_transcript.epoch(), switched_epoch);
4280        assert_eq!(
4281            runner.provider_transcript.active_family(),
4282            Some(ProviderFamily::Anthropic)
4283        );
4284        assert_eq!(
4285            runner.provider_transcript.last_reset_reason(),
4286            Some(ProviderTranscriptResetReason::ProviderSwitch)
4287        );
4288        assert!(replayable_openai(&runner).is_empty());
4289    }
4290
4291    #[test]
4292    fn provider_switch_starts_a_new_epoch_and_filters_foreign_json() {
4293        let mut session = Session::new("session-native", "model");
4294        let assistant = Message::assistant("", None);
4295        let anchor = assistant.id.clone();
4296        session.add_message(assistant);
4297        activate_openai(&mut session);
4298        session
4299            .append_provider_transcript_group(&anchor, None, openai_output_items())
4300            .unwrap();
4301        let old_epoch = session.provider_transcript.epoch();
4302
4303        assert!(activate_anthropic(&mut session));
4304        assert_eq!(session.provider_transcript.epoch(), old_epoch + 1);
4305        assert_eq!(
4306            session.provider_transcript.last_reset_reason(),
4307            Some(ProviderTranscriptResetReason::ProviderSwitch)
4308        );
4309        assert!(replayable_openai(&session).is_empty());
4310    }
4311
4312    #[test]
4313    fn same_family_provider_instance_switch_advances_and_persists_route_boundary() {
4314        let route_a_name = "openai-instance-a-sensitive-route";
4315        let route_b_name = "openai-instance-b-sensitive-route";
4316        let route_a = test_boundary(route_a_name, "openai");
4317        let route_b = test_boundary(route_b_name, "openai");
4318        assert_ne!(route_a, route_b);
4319        assert!(!route_a.contains(route_a_name));
4320
4321        let mut session = Session::new("same-family-switch", "model");
4322        let assistant = Message::assistant("native", None);
4323        let anchor = assistant.id.clone();
4324        session.add_message(assistant);
4325        assert!(session
4326            .activate_provider_transcript_route(
4327                ProviderFamily::OpenAi,
4328                ProviderProtocol::OpenAiResponsesV1,
4329                &route_a,
4330            )
4331            .unwrap());
4332        session
4333            .append_provider_transcript_group(&anchor, None, openai_output_items())
4334            .unwrap();
4335        let first_epoch = session.provider_transcript.epoch();
4336        assert_eq!(
4337            session
4338                .provider_transcript
4339                .active_provider_boundary_sha256(),
4340            Some(route_a.as_str())
4341        );
4342        assert!(!session
4343            .activate_provider_transcript_route(
4344                ProviderFamily::OpenAi,
4345                ProviderProtocol::OpenAiResponsesV1,
4346                &route_a,
4347            )
4348            .unwrap());
4349        assert_eq!(session.provider_transcript.epoch(), first_epoch);
4350
4351        let encoded = serde_json::to_string(&session.provider_transcript).unwrap();
4352        assert!(!encoded.contains(route_a_name));
4353        assert!(!encoded.contains(route_b_name));
4354
4355        let mut active_boundary_tamper: Value = serde_json::from_str(&encoded).unwrap();
4356        active_boundary_tamper["active_provider_boundary_sha256"] = json!(route_b);
4357        assert!(
4358            serde_json::from_value::<ProviderTranscriptState>(active_boundary_tamper).is_err(),
4359            "a valid but foreign active boundary must not reclassify current groups"
4360        );
4361
4362        let mut group_boundary_tamper: Value = serde_json::from_str(&encoded).unwrap();
4363        group_boundary_tamper["groups"][0]["provider_boundary_sha256"] = json!(route_b);
4364        assert!(
4365            serde_json::from_value::<ProviderTranscriptState>(group_boundary_tamper).is_err(),
4366            "a valid but foreign group boundary must invalidate its stable id"
4367        );
4368
4369        let mut resumed: ProviderTranscriptState = serde_json::from_str(&encoded).unwrap();
4370        assert_eq!(
4371            resumed.active_provider_boundary_sha256(),
4372            Some(route_a.as_str())
4373        );
4374        assert!(resumed
4375            .activate_route(
4376                ProviderFamily::OpenAi,
4377                ProviderProtocol::OpenAiResponsesV1,
4378                &route_b,
4379            )
4380            .unwrap());
4381        assert_eq!(resumed.epoch(), first_epoch + 1);
4382        assert_eq!(
4383            resumed.last_reset_reason(),
4384            Some(ProviderTranscriptResetReason::ProviderSwitch)
4385        );
4386        assert!(resumed
4387            .replayable_groups(
4388                ProviderFamily::OpenAi,
4389                ProviderProtocol::OpenAiResponsesV1,
4390                &route_a,
4391            )
4392            .is_empty());
4393        assert!(resumed
4394            .replayable_groups(
4395                ProviderFamily::OpenAi,
4396                ProviderProtocol::OpenAiResponsesV1,
4397                &route_b,
4398            )
4399            .is_empty());
4400
4401        let mut invalid = serde_json::to_value(&resumed).unwrap();
4402        invalid["active_provider_boundary_sha256"] = json!("not-a-sha256");
4403        assert!(serde_json::from_value::<ProviderTranscriptState>(invalid).is_err());
4404    }
4405
4406    #[test]
4407    fn stale_same_family_route_cannot_resurrect_groups_during_merge() {
4408        let route_a = test_boundary("openai-instance-a", "openai");
4409        let route_b = test_boundary("openai-instance-b", "openai");
4410        let mut base = Session::new("same-family-merge", "model");
4411        let first = Message::assistant("first", None);
4412        let first_anchor = first.id.clone();
4413        let second = Message::assistant("second", None);
4414        let second_anchor = second.id.clone();
4415        base.add_message(first);
4416        base.add_message(second);
4417        base.activate_provider_transcript_route(
4418            ProviderFamily::OpenAi,
4419            ProviderProtocol::OpenAiResponsesV1,
4420            &route_a,
4421        )
4422        .unwrap();
4423        base.append_provider_transcript_group(&first_anchor, None, openai_output_items())
4424            .unwrap();
4425
4426        let mut durable = base.clone();
4427        durable
4428            .activate_provider_transcript_route(
4429                ProviderFamily::OpenAi,
4430                ProviderProtocol::OpenAiResponsesV1,
4431                &route_b,
4432            )
4433            .unwrap();
4434        let switched_epoch = durable.provider_transcript.epoch();
4435
4436        let mut stale_runner = base;
4437        stale_runner
4438            .append_provider_transcript_group(&second_anchor, None, openai_output_items())
4439            .unwrap();
4440        stale_runner.merge_provider_transcript_from_durable(&durable);
4441        assert_eq!(stale_runner.provider_transcript.epoch(), switched_epoch);
4442        assert_eq!(
4443            stale_runner
4444                .provider_transcript
4445                .active_provider_boundary_sha256(),
4446            Some(route_b.as_str())
4447        );
4448        assert!(stale_runner
4449            .provider_transcript
4450            .replayable_groups(
4451                ProviderFamily::OpenAi,
4452                ProviderProtocol::OpenAiResponsesV1,
4453                &route_b,
4454            )
4455            .is_empty());
4456        assert_eq!(
4457            stale_runner.provider_transcript.last_reset_reason(),
4458            Some(ProviderTranscriptResetReason::ProviderSwitch)
4459        );
4460    }
4461
4462    #[test]
4463    fn debug_and_diagnostics_never_emit_raw_payload() {
4464        let secret = "credential-do-not-log";
4465        let item = ProviderTranscriptItem::try_from_payload(
4466            ProviderFamily::OpenAi,
4467            ProviderProtocol::OpenAiResponsesV1,
4468            ProviderTranscriptOrigin::DeveloperContext,
4469            ProviderTranscriptAuthor::Host,
4470            json!({
4471                "type":"additional_tools",
4472                "role":"developer",
4473                "tools":[{
4474                    "type":"function","name":"x","description":secret,
4475                    "parameters":{"type":"object"},"strict":false
4476                }]
4477            }),
4478        )
4479        .unwrap();
4480        assert!(!format!("{item:?}").contains(secret));
4481        let group = test_group(secret, vec![item]).unwrap();
4482        assert!(!format!("{group:?}").contains(secret));
4483
4484        let error = ProviderTranscriptItem::try_from_payload(
4485            ProviderFamily::OpenAi,
4486            ProviderProtocol::OpenAiResponsesV1,
4487            ProviderTranscriptOrigin::Provider,
4488            ProviderTranscriptAuthor::Model,
4489            json!({"type":secret}),
4490        )
4491        .unwrap_err();
4492        assert!(!format!("{error:?} {error}").contains(secret));
4493    }
4494}