1use 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#[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 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#[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
68pub 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#[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#[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
306impl 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#[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 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#[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 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 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 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 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 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 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 continue;
948 }
949 } else if group.epoch > merged.epoch {
950 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 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 ProviderOutput,
1146 HostInput,
1151 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 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 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 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
2472pub 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 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 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}