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 if self.is_empty() {
783 return;
784 }
785 self.epoch = self.epoch.saturating_add(1);
786 self.next_sequence = 0;
787 self.last_reset_reason = Some(reason);
788 self.state_revision = self.state_revision.saturating_add(1);
789 }
790
791 pub fn append_group(
792 &mut self,
793 anchor_message_id: impl Into<String>,
794 id_hint: Option<&str>,
795 items: Vec<ProviderTranscriptItem>,
796 ) -> Result<String, ProviderTranscriptError> {
797 let first = items.first().ok_or(ProviderTranscriptError::EmptyGroup)?;
798 let family = first.family;
799 let protocol = first.protocol;
800 let (provider_boundary_sha256, activates_unbound_route) = match (
801 self.active_family,
802 self.active_protocol,
803 self.active_provider_boundary_sha256.as_deref(),
804 ) {
805 (Some(active_family), Some(active_protocol), Some(boundary))
806 if active_family == family && active_protocol == protocol =>
807 {
808 (boundary.to_string(), false)
809 }
810 (Some(_), Some(_), Some(_)) => {
811 return Err(ProviderTranscriptError::InactiveProviderRoute)
812 }
813 (None, None, None) => (unbound_provider_boundary_sha256(), true),
814 _ => return Err(ProviderTranscriptError::InactiveProviderRoute),
815 };
816 let group = ProviderTranscriptGroup::new(
820 self.epoch,
821 self.next_sequence,
822 anchor_message_id.into(),
823 id_hint,
824 provider_boundary_sha256.clone(),
825 items,
826 )?;
827 if let Some(existing) = self.groups.iter().find(|current| current.id == group.id) {
828 if existing.epoch == group.epoch
829 && existing.anchor_message_id == group.anchor_message_id
830 && existing.family == group.family
831 && existing.protocol == group.protocol
832 && existing.provider_boundary_sha256 == group.provider_boundary_sha256
833 && existing.items == group.items
834 {
835 return Ok(group.id);
836 }
837 return Err(ProviderTranscriptError::DuplicateGroupId);
838 }
839 if activates_unbound_route {
840 self.active_family = Some(family);
841 self.active_protocol = Some(protocol);
842 self.active_provider_boundary_sha256 = Some(provider_boundary_sha256);
843 self.state_revision = self.state_revision.saturating_add(1);
844 }
845 let id = group.id.clone();
846 self.groups.push(group);
847 self.next_sequence = self.next_sequence.saturating_add(1);
848 self.state_revision = self.state_revision.saturating_add(1);
849 Ok(id)
850 }
851
852 pub fn prune_dangling_groups(&mut self, live_message_ids: &HashSet<String>) -> usize {
855 let before = self.groups.len();
856 self.groups
857 .retain(|group| live_message_ids.contains(group.anchor_message_id()));
858 let removed = before.saturating_sub(self.groups.len());
859 if removed > 0 {
860 self.state_revision = self.state_revision.saturating_add(1);
861 self.last_reset_reason = Some(ProviderTranscriptResetReason::Rollback);
862 }
863 removed
864 }
865
866 pub fn replayable_groups(
867 &self,
868 family: ProviderFamily,
869 protocol: ProviderProtocol,
870 provider_boundary_sha256: &str,
871 ) -> Vec<&ProviderTranscriptGroup> {
872 if self.active_family != Some(family)
873 || self.active_protocol != Some(protocol)
874 || self.active_provider_boundary_sha256.as_deref() != Some(provider_boundary_sha256)
875 || !protocol.supports_family(family)
876 {
877 return Vec::new();
878 }
879 let mut groups = self
880 .groups
881 .iter()
882 .filter(|group| {
883 group.epoch == self.epoch
884 && group.family == family
885 && group.protocol == protocol
886 && group.provider_boundary_sha256 == provider_boundary_sha256
887 })
888 .collect::<Vec<_>>();
889 groups.sort_by_key(|group| group.sequence);
890 groups
891 }
892
893 pub fn merge_durable_prefix(
898 &mut self,
899 durable: &ProviderTranscriptState,
900 ordered_message_ids: &[String],
901 ) -> usize {
902 let live = self.clone();
903 let durable_is_newer = durable.epoch > live.epoch
904 || (durable.epoch == live.epoch && durable.state_revision >= live.state_revision);
905 let mut merged = if durable_is_newer {
906 durable.clone()
907 } else {
908 live.clone()
909 };
910 let live_message_ids = ordered_message_ids.iter().cloned().collect::<HashSet<_>>();
911 let message_order = ordered_message_ids
912 .iter()
913 .enumerate()
914 .map(|(index, id)| (id.as_str(), index))
915 .collect::<HashMap<_, _>>();
916 let mut seen = merged
917 .groups
918 .iter()
919 .map(|group| group.id.clone())
920 .collect::<HashSet<_>>();
921 let mut added = 0usize;
922 for group in durable.groups.iter().chain(live.groups.iter()) {
923 if live_message_ids.contains(group.anchor_message_id()) && seen.insert(group.id.clone())
924 {
925 let group = group.clone();
926 if group.epoch == merged.epoch {
927 if merged.active_family != Some(group.family)
928 || merged.active_protocol != Some(group.protocol)
929 || merged.active_provider_boundary_sha256.as_deref()
930 != Some(group.provider_boundary_sha256())
931 {
932 continue;
938 }
939 } else if group.epoch > merged.epoch {
940 continue;
944 }
945 merged.groups.push(group);
946 added = added.saturating_add(1);
947 }
948 }
949 merged.prune_dangling_groups(&live_message_ids);
950
951 let before_normalization = merged.groups.clone();
956 let mut historical = Vec::new();
957 let mut current = Vec::new();
958 for group in std::mem::take(&mut merged.groups) {
959 if group.epoch == merged.epoch {
960 current.push(group);
961 } else {
962 historical.push(group);
963 }
964 }
965 current.sort_by(|left, right| {
966 message_order
967 .get(left.anchor_message_id())
968 .copied()
969 .unwrap_or(usize::MAX)
970 .cmp(
971 &message_order
972 .get(right.anchor_message_id())
973 .copied()
974 .unwrap_or(usize::MAX),
975 )
976 .then_with(|| left.sequence.cmp(&right.sequence))
977 .then_with(|| left.id.cmp(&right.id))
978 });
979 for (sequence, group) in current.iter_mut().enumerate() {
980 group.sequence = sequence as u64;
981 }
982 merged.next_sequence = current.len() as u64;
983 historical.extend(current);
984 merged.groups = historical;
985 let normalized = merged.groups != before_normalization;
986 if added > 0 || normalized {
987 merged.state_revision = merged.state_revision.saturating_add(1);
988 }
989 *self = merged;
990 added
991 }
992
993 pub fn diagnostics(
994 &self,
995 family: ProviderFamily,
996 protocol: ProviderProtocol,
997 provider_boundary_sha256: &str,
998 ) -> ProviderTranscriptDiagnostics {
999 let groups = self.replayable_groups(family, protocol, provider_boundary_sha256);
1000 let item_count = groups.iter().map(|group| group.items.len()).sum();
1001 let bytes = serde_json::to_vec(&groups).unwrap_or_default();
1002 ProviderTranscriptDiagnostics {
1003 group_count: groups.len(),
1004 item_count,
1005 serialized_bytes: bytes.len(),
1006 sha256: hash_bytes(TRANSCRIPT_HASH_DOMAIN, &bytes),
1007 }
1008 }
1009}
1010
1011impl Session {
1012 pub fn activate_provider_transcript_route(
1013 &mut self,
1014 family: ProviderFamily,
1015 protocol: ProviderProtocol,
1016 provider_boundary_sha256: &str,
1017 ) -> Result<bool, ProviderTranscriptError> {
1018 let changed =
1019 self.provider_transcript
1020 .activate_route(family, protocol, provider_boundary_sha256)?;
1021 if changed {
1022 self.updated_at = chrono::Utc::now();
1023 }
1024 Ok(changed)
1025 }
1026
1027 pub fn deactivate_provider_transcript_route(&mut self) -> bool {
1028 let changed = self.provider_transcript.deactivate_route();
1029 if changed {
1030 self.updated_at = chrono::Utc::now();
1031 }
1032 changed
1033 }
1034
1035 pub fn append_provider_transcript_group(
1036 &mut self,
1037 anchor_message_id: impl Into<String>,
1038 id_hint: Option<&str>,
1039 items: Vec<ProviderTranscriptItem>,
1040 ) -> Result<String, ProviderTranscriptError> {
1041 let anchor_message_id = anchor_message_id.into();
1042 if !self
1043 .messages
1044 .iter()
1045 .any(|message| message.id == anchor_message_id)
1046 {
1047 return Err(ProviderTranscriptError::InvalidAnchor);
1048 }
1049 let id = self
1050 .provider_transcript
1051 .append_group(anchor_message_id, id_hint, items)?;
1052 self.updated_at = chrono::Utc::now();
1053 Ok(id)
1054 }
1055
1056 pub fn prune_provider_transcript(&mut self) -> usize {
1057 let live_message_ids = self
1058 .messages
1059 .iter()
1060 .map(|message| message.id.clone())
1061 .collect::<HashSet<_>>();
1062 self.provider_transcript
1063 .prune_dangling_groups(&live_message_ids)
1064 }
1065
1066 pub fn invalidate_provider_transcript(&mut self, reason: ProviderTranscriptResetReason) {
1067 self.provider_transcript.invalidate(reason);
1068 self.updated_at = chrono::Utc::now();
1069 }
1070
1071 pub fn merge_provider_transcript_from_durable(&mut self, durable: &Session) -> usize {
1072 let ordered_message_ids = self
1073 .messages
1074 .iter()
1075 .map(|message| message.id.clone())
1076 .collect::<Vec<_>>();
1077 self.provider_transcript
1078 .merge_durable_prefix(&durable.provider_transcript, &ordered_message_ids)
1079 }
1080}
1081
1082fn infer_and_validate_item(
1083 protocol: ProviderProtocol,
1084 origin: ProviderTranscriptOrigin,
1085 author: ProviderTranscriptAuthor,
1086 payload: &Value,
1087) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1088 let object = payload
1089 .as_object()
1090 .ok_or(ProviderTranscriptError::PayloadNotObject)?;
1091 let item_type = object
1092 .get("type")
1093 .and_then(Value::as_str)
1094 .ok_or(ProviderTranscriptError::InvalidItem("missing item type"))?;
1095
1096 match protocol {
1097 ProviderProtocol::OpenAiResponsesV1 => {
1098 validate_openai_item(item_type, origin, author, payload)
1099 }
1100 ProviderProtocol::AnthropicMessages2023_06_01 => {
1101 validate_anthropic_item(item_type, origin, author, payload)
1102 }
1103 }
1104}
1105
1106fn reject_unknown_fields(
1107 object: &serde_json::Map<String, Value>,
1108 allowed: &[&str],
1109 label: &'static str,
1110) -> Result<(), ProviderTranscriptError> {
1111 if object
1112 .keys()
1113 .any(|field| !allowed.contains(&field.as_str()))
1114 {
1115 return Err(ProviderTranscriptError::InvalidItem(label));
1116 }
1117 Ok(())
1118}
1119
1120fn validate_allowed_callers(value: Option<&Value>) -> bool {
1121 value.is_none_or(|value| {
1122 value.is_null()
1123 || value.as_array().is_some_and(|callers| {
1124 callers
1125 .iter()
1126 .all(|caller| matches!(caller.as_str(), Some("direct" | "programmatic")))
1127 })
1128 })
1129}
1130
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132enum OpenAiFunctionDefinitionMode {
1133 ProviderOutput,
1136 HostInput,
1141 NamespaceMember,
1145}
1146
1147fn validate_openai_function_definition(
1148 object: &serde_json::Map<String, Value>,
1149 mode: OpenAiFunctionDefinitionMode,
1150) -> Result<(), ProviderTranscriptError> {
1151 reject_unknown_fields(
1152 object,
1153 &[
1154 "type",
1155 "name",
1156 "parameters",
1157 "strict",
1158 "allowed_callers",
1159 "defer_loading",
1160 "description",
1161 "output_schema",
1162 ],
1163 "function definition fields",
1164 )?;
1165 let parameters_valid = match mode {
1166 OpenAiFunctionDefinitionMode::HostInput => object
1167 .get("parameters")
1168 .is_some_and(|parameters| parameters.is_null() || parameters.is_object()),
1169 OpenAiFunctionDefinitionMode::ProviderOutput
1170 | OpenAiFunctionDefinitionMode::NamespaceMember => object
1171 .get("parameters")
1172 .is_none_or(|parameters| parameters.is_null() || parameters.is_object()),
1173 };
1174 let nullable_optional = |value: Option<&Value>, predicate: fn(&Value) -> bool| {
1175 value.is_none_or(|value| value.is_null() || predicate(value))
1176 };
1177 if object.get("type").and_then(Value::as_str) != Some("function")
1178 || !object
1179 .get("name")
1180 .and_then(Value::as_str)
1181 .is_some_and(|name| !name.trim().is_empty())
1182 || !parameters_valid
1183 || match mode {
1184 OpenAiFunctionDefinitionMode::HostInput => !object
1185 .get("strict")
1186 .is_some_and(|strict| strict.is_null() || strict.is_boolean()),
1187 OpenAiFunctionDefinitionMode::ProviderOutput
1188 | OpenAiFunctionDefinitionMode::NamespaceMember => {
1189 !nullable_optional(object.get("strict"), Value::is_boolean)
1190 }
1191 }
1192 || !nullable_optional(object.get("defer_loading"), Value::is_boolean)
1193 || !nullable_optional(object.get("description"), Value::is_string)
1194 || !nullable_optional(object.get("output_schema"), Value::is_object)
1195 || !validate_allowed_callers(object.get("allowed_callers"))
1196 {
1197 return Err(ProviderTranscriptError::InvalidItem(
1198 "function definition shape",
1199 ));
1200 }
1201 Ok(())
1202}
1203
1204fn validate_openai_tool_definition(
1205 value: &Value,
1206 mode: OpenAiFunctionDefinitionMode,
1207) -> Result<(), ProviderTranscriptError> {
1208 let object = value
1209 .as_object()
1210 .ok_or(ProviderTranscriptError::InvalidItem("tool definition"))?;
1211 match object.get("type").and_then(Value::as_str) {
1212 Some("function") => validate_openai_function_definition(object, mode),
1213 Some("namespace") => {
1214 reject_unknown_fields(
1215 object,
1216 &["type", "name", "description", "tools"],
1217 "namespace definition fields",
1218 )?;
1219 let tools = object
1220 .get("tools")
1221 .and_then(Value::as_array)
1222 .ok_or(ProviderTranscriptError::InvalidItem("namespace tools"))?;
1223 if !object
1224 .get("name")
1225 .and_then(Value::as_str)
1226 .is_some_and(|name| !name.trim().is_empty())
1227 || !object
1228 .get("description")
1229 .and_then(Value::as_str)
1230 .is_some_and(|description| !description.trim().is_empty())
1231 {
1232 return Err(ProviderTranscriptError::InvalidItem(
1233 "namespace definition shape",
1234 ));
1235 }
1236 for tool in tools {
1237 let nested = tool
1238 .as_object()
1239 .ok_or(ProviderTranscriptError::InvalidItem(
1240 "namespace tool definition",
1241 ))?;
1242 validate_openai_function_definition(
1246 nested,
1247 OpenAiFunctionDefinitionMode::NamespaceMember,
1248 )?;
1249 }
1250 Ok(())
1251 }
1252 _ => Err(ProviderTranscriptError::InvalidItem(
1253 "unsupported loaded tool definition",
1254 )),
1255 }
1256}
1257
1258fn validate_openai_tool_definitions(
1259 value: Option<&Value>,
1260 mode: OpenAiFunctionDefinitionMode,
1261) -> Result<(), ProviderTranscriptError> {
1262 let tools = value
1263 .and_then(Value::as_array)
1264 .ok_or(ProviderTranscriptError::InvalidItem("loaded tools"))?;
1265 for tool in tools {
1266 validate_openai_tool_definition(tool, mode)?;
1267 }
1268 Ok(())
1269}
1270
1271fn is_nonempty_string(value: Option<&Value>) -> bool {
1272 value
1273 .and_then(Value::as_str)
1274 .is_some_and(|value| !value.trim().is_empty())
1275}
1276
1277fn is_optional_nullable_nonempty_string(value: Option<&Value>) -> bool {
1278 value.is_none_or(|value| {
1279 value.is_null() || value.as_str().is_some_and(|value| !value.trim().is_empty())
1280 })
1281}
1282
1283fn is_optional_nullable_item_status(value: Option<&Value>) -> bool {
1284 value.is_none_or(|value| value.is_null() || is_openai_item_status(Some(value)))
1285}
1286
1287fn is_openai_item_status(value: Option<&Value>) -> bool {
1288 matches!(
1289 value.and_then(Value::as_str),
1290 Some("in_progress" | "completed" | "incomplete")
1291 )
1292}
1293
1294fn validate_openai_agent(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1295 let Some(value) = value else {
1296 return Ok(());
1297 };
1298 if value.is_null() {
1299 return Ok(());
1300 }
1301 let agent = value
1302 .as_object()
1303 .ok_or(ProviderTranscriptError::InvalidItem("agent shape"))?;
1304 reject_unknown_fields(agent, &["agent_name"], "agent fields")?;
1305 if !is_nonempty_string(agent.get("agent_name")) {
1306 return Err(ProviderTranscriptError::InvalidItem("agent name"));
1307 }
1308 Ok(())
1309}
1310
1311fn validate_openai_annotation(value: &Value) -> Result<(), ProviderTranscriptError> {
1312 let annotation = value
1313 .as_object()
1314 .ok_or(ProviderTranscriptError::InvalidItem("output annotation"))?;
1315 let string = |field| annotation.get(field).is_some_and(Value::is_string);
1316 let index = |field| annotation.get(field).and_then(Value::as_u64).is_some();
1317 match annotation.get("type").and_then(Value::as_str) {
1318 Some("file_citation") => {
1319 reject_unknown_fields(
1320 annotation,
1321 &["type", "file_id", "filename", "index"],
1322 "file citation fields",
1323 )?;
1324 if !string("file_id") || !string("filename") || !index("index") {
1325 return Err(ProviderTranscriptError::InvalidItem("file citation shape"));
1326 }
1327 }
1328 Some("url_citation") => {
1329 reject_unknown_fields(
1330 annotation,
1331 &["type", "start_index", "end_index", "title", "url"],
1332 "url citation fields",
1333 )?;
1334 if !index("start_index") || !index("end_index") || !string("title") || !string("url") {
1335 return Err(ProviderTranscriptError::InvalidItem("url citation shape"));
1336 }
1337 }
1338 Some("container_file_citation") => {
1339 reject_unknown_fields(
1340 annotation,
1341 &[
1342 "type",
1343 "container_id",
1344 "start_index",
1345 "end_index",
1346 "file_id",
1347 "filename",
1348 ],
1349 "container citation fields",
1350 )?;
1351 if !string("container_id")
1352 || !index("start_index")
1353 || !index("end_index")
1354 || !string("file_id")
1355 || !string("filename")
1356 {
1357 return Err(ProviderTranscriptError::InvalidItem(
1358 "container citation shape",
1359 ));
1360 }
1361 }
1362 Some("file_path") => {
1363 reject_unknown_fields(
1364 annotation,
1365 &["type", "file_id", "index"],
1366 "file path fields",
1367 )?;
1368 if !string("file_id") || !index("index") {
1369 return Err(ProviderTranscriptError::InvalidItem("file path shape"));
1370 }
1371 }
1372 _ => {
1373 return Err(ProviderTranscriptError::InvalidItem(
1374 "unsupported output annotation",
1375 ))
1376 }
1377 }
1378 Ok(())
1379}
1380
1381fn validate_openai_logprob_bytes(value: Option<&Value>) -> bool {
1382 value.and_then(Value::as_array).is_some_and(|bytes| {
1383 bytes
1384 .iter()
1385 .all(|byte| byte.as_u64().is_some_and(|byte| byte <= u8::MAX as u64))
1386 })
1387}
1388
1389fn validate_openai_logprobs(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1390 let Some(value) = value else {
1391 return Ok(());
1392 };
1393 if value.is_null() {
1394 return Ok(());
1395 }
1396 let logprobs = value
1397 .as_array()
1398 .ok_or(ProviderTranscriptError::InvalidItem("output logprobs"))?;
1399 for logprob in logprobs {
1400 let logprob = logprob
1401 .as_object()
1402 .ok_or(ProviderTranscriptError::InvalidItem("output logprob"))?;
1403 reject_unknown_fields(
1404 logprob,
1405 &["token", "bytes", "logprob", "top_logprobs"],
1406 "output logprob fields",
1407 )?;
1408 let top_logprobs = logprob
1409 .get("top_logprobs")
1410 .and_then(Value::as_array)
1411 .ok_or(ProviderTranscriptError::InvalidItem("top logprobs"))?;
1412 if !logprob.get("token").is_some_and(Value::is_string)
1413 || !validate_openai_logprob_bytes(logprob.get("bytes"))
1414 || !logprob.get("logprob").is_some_and(Value::is_number)
1415 {
1416 return Err(ProviderTranscriptError::InvalidItem("output logprob shape"));
1417 }
1418 for top in top_logprobs {
1419 let top = top
1420 .as_object()
1421 .ok_or(ProviderTranscriptError::InvalidItem("top logprob"))?;
1422 reject_unknown_fields(top, &["token", "bytes", "logprob"], "top logprob fields")?;
1423 if !top.get("token").is_some_and(Value::is_string)
1424 || !validate_openai_logprob_bytes(top.get("bytes"))
1425 || !top.get("logprob").is_some_and(Value::is_number)
1426 {
1427 return Err(ProviderTranscriptError::InvalidItem("top logprob shape"));
1428 }
1429 }
1430 }
1431 Ok(())
1432}
1433
1434fn validate_openai_message_content(value: &Value) -> Result<(), ProviderTranscriptError> {
1435 let content = value
1436 .as_array()
1437 .ok_or(ProviderTranscriptError::InvalidItem("message content"))?;
1438 for part in content {
1439 let part = part
1440 .as_object()
1441 .ok_or(ProviderTranscriptError::InvalidItem("message content part"))?;
1442 match part.get("type").and_then(Value::as_str) {
1443 Some("output_text") => {
1444 reject_unknown_fields(
1445 part,
1446 &["type", "text", "annotations", "logprobs"],
1447 "output text fields",
1448 )?;
1449 let annotations = part
1450 .get("annotations")
1451 .and_then(Value::as_array)
1452 .ok_or(ProviderTranscriptError::InvalidItem("output annotations"))?;
1453 if !part.get("text").is_some_and(Value::is_string) {
1454 return Err(ProviderTranscriptError::InvalidItem("output text content"));
1455 }
1456 for annotation in annotations {
1457 validate_openai_annotation(annotation)?;
1458 }
1459 validate_openai_logprobs(part.get("logprobs"))?;
1460 }
1461 Some("refusal") => {
1462 reject_unknown_fields(part, &["type", "refusal"], "refusal fields")?;
1463 if !part.get("refusal").is_some_and(Value::is_string) {
1464 return Err(ProviderTranscriptError::InvalidItem("refusal content"));
1465 }
1466 }
1467 _ => {
1468 return Err(ProviderTranscriptError::InvalidItem(
1469 "unsupported message content",
1470 ))
1471 }
1472 }
1473 }
1474 Ok(())
1475}
1476
1477fn validate_openai_reasoning_parts(
1478 value: Option<&Value>,
1479 kind: &'static str,
1480 required: bool,
1481) -> Result<(), ProviderTranscriptError> {
1482 let Some(value) = value else {
1483 return if required {
1484 Err(ProviderTranscriptError::InvalidItem("reasoning parts"))
1485 } else {
1486 Ok(())
1487 };
1488 };
1489 if value.is_null() && !required {
1490 return Ok(());
1491 }
1492 let parts = value
1493 .as_array()
1494 .ok_or(ProviderTranscriptError::InvalidItem("reasoning parts"))?;
1495 for part in parts {
1496 let part = part
1497 .as_object()
1498 .ok_or(ProviderTranscriptError::InvalidItem("reasoning part"))?;
1499 reject_unknown_fields(part, &["type", "text"], "reasoning part fields")?;
1500 if part.get("type").and_then(Value::as_str) != Some(kind)
1501 || !part.get("text").is_some_and(Value::is_string)
1502 {
1503 return Err(ProviderTranscriptError::InvalidItem("reasoning part shape"));
1504 }
1505 }
1506 Ok(())
1507}
1508
1509fn validate_openai_caller(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1510 let Some(value) = value else {
1511 return Ok(());
1512 };
1513 if value.is_null() {
1514 return Ok(());
1515 }
1516 let caller = value
1517 .as_object()
1518 .ok_or(ProviderTranscriptError::InvalidItem("function caller"))?;
1519 match caller.get("type").and_then(Value::as_str) {
1520 Some("direct") => reject_unknown_fields(caller, &["type"], "direct caller fields"),
1521 Some("program") => {
1522 reject_unknown_fields(caller, &["type", "caller_id"], "program caller fields")?;
1523 if !is_nonempty_string(caller.get("caller_id")) {
1524 return Err(ProviderTranscriptError::InvalidItem("program caller id"));
1525 }
1526 Ok(())
1527 }
1528 _ => Err(ProviderTranscriptError::InvalidItem(
1529 "unsupported function caller",
1530 )),
1531 }
1532}
1533
1534fn validate_openai_item(
1535 item_type: &str,
1536 origin: ProviderTranscriptOrigin,
1537 author: ProviderTranscriptAuthor,
1538 payload: &Value,
1539) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1540 let object = payload.as_object().expect("validated object");
1541 let require_nonempty_string = |field: &'static str| {
1542 object
1543 .get(field)
1544 .and_then(Value::as_str)
1545 .filter(|value| !value.trim().is_empty())
1546 .ok_or(ProviderTranscriptError::InvalidItem(field))
1547 };
1548 let execution = || {
1549 object
1550 .get("execution")
1551 .and_then(Value::as_str)
1552 .filter(|value| matches!(*value, "server" | "client"))
1553 .ok_or(ProviderTranscriptError::InvalidItem("execution"))
1554 };
1555
1556 match item_type {
1557 "message" if origin == ProviderTranscriptOrigin::Provider => {
1558 reject_unknown_fields(
1559 object,
1560 &["type", "id", "content", "role", "status", "agent", "phase"],
1561 "provider message fields",
1562 )?;
1563 if author != ProviderTranscriptAuthor::Model
1564 || !is_nonempty_string(object.get("id"))
1565 || object.get("role").and_then(Value::as_str) != Some("assistant")
1566 || !is_openai_item_status(object.get("status"))
1567 || object.get("phase").is_some_and(|phase| {
1568 !phase.is_null()
1569 && !matches!(phase.as_str(), Some("commentary" | "final_answer"))
1570 })
1571 {
1572 return Err(ProviderTranscriptError::InvalidItem(
1573 "provider message shape",
1574 ));
1575 }
1576 validate_openai_agent(object.get("agent"))?;
1577 validate_openai_message_content(object.get("content").ok_or(
1578 ProviderTranscriptError::InvalidItem("provider message content"),
1579 )?)?;
1580 Ok(ProviderTranscriptItemKind::OpenAiMessage)
1581 }
1582 "reasoning" if origin == ProviderTranscriptOrigin::Provider => {
1583 reject_unknown_fields(
1584 object,
1585 &[
1586 "type",
1587 "id",
1588 "summary",
1589 "agent",
1590 "content",
1591 "encrypted_content",
1592 "status",
1593 ],
1594 "reasoning fields",
1595 )?;
1596 if author != ProviderTranscriptAuthor::Model
1597 || !is_nonempty_string(object.get("id"))
1598 || !object
1599 .get("encrypted_content")
1600 .is_none_or(|content| content.is_null() || content.is_string())
1601 || !is_optional_nullable_item_status(object.get("status"))
1602 {
1603 return Err(ProviderTranscriptError::InvalidItem("reasoning shape"));
1604 }
1605 validate_openai_agent(object.get("agent"))?;
1606 validate_openai_reasoning_parts(object.get("summary"), "summary_text", true)?;
1607 validate_openai_reasoning_parts(object.get("content"), "reasoning_text", false)?;
1608 Ok(ProviderTranscriptItemKind::OpenAiReasoning)
1609 }
1610 "function_call" if origin == ProviderTranscriptOrigin::Provider => {
1611 reject_unknown_fields(
1612 object,
1613 &[
1614 "type",
1615 "id",
1616 "arguments",
1617 "call_id",
1618 "name",
1619 "agent",
1620 "caller",
1621 "namespace",
1622 "status",
1623 "created_by",
1624 ],
1625 "function call fields",
1626 )?;
1627 require_nonempty_string("name")?;
1628 require_nonempty_string("call_id")?;
1629 let arguments = object.get("arguments").and_then(Value::as_str);
1630 if author != ProviderTranscriptAuthor::Model
1631 || !is_optional_nullable_nonempty_string(object.get("id"))
1632 || !is_optional_nullable_nonempty_string(object.get("namespace"))
1633 || !is_optional_nullable_item_status(object.get("status"))
1634 || !is_optional_nullable_nonempty_string(object.get("created_by"))
1635 || arguments.is_none()
1636 || !arguments.is_some_and(|arguments| {
1637 serde_json::from_str::<Value>(arguments)
1638 .ok()
1639 .is_some_and(|value| value.is_object())
1640 })
1641 {
1642 return Err(ProviderTranscriptError::InvalidItem("function call shape"));
1643 }
1644 validate_openai_agent(object.get("agent"))?;
1645 validate_openai_caller(object.get("caller"))?;
1646 Ok(ProviderTranscriptItemKind::OpenAiFunctionCall)
1647 }
1648 "tool_search_call" if origin == ProviderTranscriptOrigin::Provider => {
1649 reject_unknown_fields(
1650 object,
1651 &[
1652 "type",
1653 "id",
1654 "arguments",
1655 "call_id",
1656 "execution",
1657 "status",
1658 "agent",
1659 "created_by",
1660 ],
1661 "tool search call fields",
1662 )?;
1663 let execution = execution()?;
1664 require_nonempty_string("id")?;
1665 let call_id_valid = if execution == "client" {
1666 is_nonempty_string(object.get("call_id"))
1667 } else {
1668 is_optional_nullable_nonempty_string(object.get("call_id"))
1669 };
1670 if author != ProviderTranscriptAuthor::Model
1671 || object.get("status").and_then(Value::as_str) != Some("completed")
1672 || !object.get("arguments").is_some_and(Value::is_object)
1673 || !call_id_valid
1674 || !is_optional_nullable_nonempty_string(object.get("created_by"))
1675 {
1676 return Err(ProviderTranscriptError::InvalidItem(
1677 "tool search call shape",
1678 ));
1679 }
1680 validate_openai_agent(object.get("agent"))?;
1681 Ok(ProviderTranscriptItemKind::OpenAiToolSearchCall)
1682 }
1683 "tool_search_output" => {
1684 let execution = execution()?;
1685 if author != ProviderTranscriptAuthor::ToolResult
1686 || object.get("status").and_then(Value::as_str) != Some("completed")
1687 {
1688 return Err(ProviderTranscriptError::InvalidItem(
1689 "tool search output shape",
1690 ));
1691 }
1692 match (execution, origin) {
1693 ("server", ProviderTranscriptOrigin::Provider) => {
1694 reject_unknown_fields(
1695 object,
1696 &[
1697 "type",
1698 "id",
1699 "call_id",
1700 "execution",
1701 "status",
1702 "tools",
1703 "agent",
1704 "created_by",
1705 ],
1706 "server tool search output fields",
1707 )?;
1708 require_nonempty_string("id")?;
1709 if !is_optional_nullable_nonempty_string(object.get("call_id"))
1710 || !is_optional_nullable_nonempty_string(object.get("created_by"))
1711 {
1712 return Err(ProviderTranscriptError::InvalidItem(
1713 "tool search output metadata",
1714 ));
1715 }
1716 validate_openai_agent(object.get("agent"))?;
1717 validate_openai_tool_definitions(
1718 object.get("tools"),
1719 OpenAiFunctionDefinitionMode::ProviderOutput,
1720 )?;
1721 }
1722 ("client", ProviderTranscriptOrigin::HostToolSearch) => {
1723 reject_unknown_fields(
1724 object,
1725 &["type", "call_id", "execution", "status", "tools"],
1726 "client tool search output fields",
1727 )?;
1728 require_nonempty_string("call_id")?;
1729 validate_openai_tool_definitions(
1730 object.get("tools"),
1731 OpenAiFunctionDefinitionMode::HostInput,
1732 )?;
1733 }
1734 _ => {
1735 return Err(ProviderTranscriptError::InvalidItem(
1736 "tool search output origin",
1737 ))
1738 }
1739 }
1740 Ok(ProviderTranscriptItemKind::OpenAiToolSearchOutput)
1741 }
1742 "additional_tools" if origin == ProviderTranscriptOrigin::DeveloperContext => {
1743 reject_unknown_fields(
1744 object,
1745 &["type", "role", "tools"],
1746 "additional tools fields",
1747 )?;
1748 if author != ProviderTranscriptAuthor::Host
1749 || object.get("role").and_then(Value::as_str) != Some("developer")
1750 {
1751 return Err(ProviderTranscriptError::InvalidItem(
1752 "additional tools shape",
1753 ));
1754 }
1755 validate_openai_tool_definitions(
1756 object.get("tools"),
1757 OpenAiFunctionDefinitionMode::HostInput,
1758 )?;
1759 Ok(ProviderTranscriptItemKind::OpenAiAdditionalTools)
1760 }
1761 _ => Err(ProviderTranscriptError::UnsupportedItemType),
1762 }
1763}
1764
1765fn validate_anthropic_caller(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1766 let Some(value) = value else {
1767 return Ok(());
1768 };
1769 let caller = value
1770 .as_object()
1771 .ok_or(ProviderTranscriptError::InvalidItem("Anthropic caller"))?;
1772 match caller.get("type").and_then(Value::as_str) {
1773 Some("direct") => reject_unknown_fields(caller, &["type"], "direct caller fields"),
1774 Some("code_execution_20250825" | "code_execution_20260120" | "code_execution_20260521") => {
1775 reject_unknown_fields(caller, &["type", "tool_id"], "server caller fields")?;
1776 if !is_nonempty_string(caller.get("tool_id")) {
1777 return Err(ProviderTranscriptError::InvalidItem(
1778 "server caller tool id",
1779 ));
1780 }
1781 Ok(())
1782 }
1783 _ => Err(ProviderTranscriptError::InvalidItem(
1784 "unsupported Anthropic caller",
1785 )),
1786 }
1787}
1788
1789fn is_string_or_null(value: Option<&Value>) -> bool {
1790 value.is_some_and(|value| value.is_string() || value.is_null())
1791}
1792
1793fn validate_anthropic_text_citation(value: &Value) -> Result<(), ProviderTranscriptError> {
1794 let citation = value
1795 .as_object()
1796 .ok_or(ProviderTranscriptError::InvalidItem("text citation"))?;
1797 let string = |field| citation.get(field).is_some_and(Value::is_string);
1798 let index = |field| citation.get(field).and_then(Value::as_u64).is_some();
1799 match citation.get("type").and_then(Value::as_str) {
1800 Some("char_location") => {
1801 reject_unknown_fields(
1802 citation,
1803 &[
1804 "type",
1805 "cited_text",
1806 "document_index",
1807 "document_title",
1808 "start_char_index",
1809 "end_char_index",
1810 "file_id",
1811 ],
1812 "char citation fields",
1813 )?;
1814 if !string("cited_text")
1815 || !index("document_index")
1816 || !is_string_or_null(citation.get("document_title"))
1817 || !index("start_char_index")
1818 || !index("end_char_index")
1819 || !is_string_or_null(citation.get("file_id"))
1820 {
1821 return Err(ProviderTranscriptError::InvalidItem("char citation shape"));
1822 }
1823 }
1824 Some("page_location") => {
1825 reject_unknown_fields(
1826 citation,
1827 &[
1828 "type",
1829 "cited_text",
1830 "document_index",
1831 "document_title",
1832 "start_page_number",
1833 "end_page_number",
1834 "file_id",
1835 ],
1836 "page citation fields",
1837 )?;
1838 if !string("cited_text")
1839 || !index("document_index")
1840 || !is_string_or_null(citation.get("document_title"))
1841 || !index("start_page_number")
1842 || !index("end_page_number")
1843 || !is_string_or_null(citation.get("file_id"))
1844 {
1845 return Err(ProviderTranscriptError::InvalidItem("page citation shape"));
1846 }
1847 }
1848 Some("content_block_location") => {
1849 reject_unknown_fields(
1850 citation,
1851 &[
1852 "type",
1853 "cited_text",
1854 "document_index",
1855 "document_title",
1856 "start_block_index",
1857 "end_block_index",
1858 "file_id",
1859 ],
1860 "content citation fields",
1861 )?;
1862 if !string("cited_text")
1863 || !index("document_index")
1864 || !is_string_or_null(citation.get("document_title"))
1865 || !index("start_block_index")
1866 || !index("end_block_index")
1867 || !is_string_or_null(citation.get("file_id"))
1868 {
1869 return Err(ProviderTranscriptError::InvalidItem(
1870 "content citation shape",
1871 ));
1872 }
1873 }
1874 Some("web_search_result_location") => {
1875 reject_unknown_fields(
1876 citation,
1877 &["type", "cited_text", "encrypted_index", "title", "url"],
1878 "web citation fields",
1879 )?;
1880 if !string("cited_text")
1881 || !string("encrypted_index")
1882 || !is_string_or_null(citation.get("title"))
1883 || !string("url")
1884 {
1885 return Err(ProviderTranscriptError::InvalidItem("web citation shape"));
1886 }
1887 }
1888 Some("search_result_location") => {
1889 reject_unknown_fields(
1890 citation,
1891 &[
1892 "type",
1893 "cited_text",
1894 "start_block_index",
1895 "end_block_index",
1896 "search_result_index",
1897 "source",
1898 "title",
1899 ],
1900 "search citation fields",
1901 )?;
1902 if !string("cited_text")
1903 || !index("start_block_index")
1904 || !index("end_block_index")
1905 || !index("search_result_index")
1906 || !string("source")
1907 || !is_string_or_null(citation.get("title"))
1908 {
1909 return Err(ProviderTranscriptError::InvalidItem(
1910 "search citation shape",
1911 ));
1912 }
1913 }
1914 _ => {
1915 return Err(ProviderTranscriptError::InvalidItem(
1916 "unsupported text citation",
1917 ))
1918 }
1919 }
1920 Ok(())
1921}
1922
1923fn validate_anthropic_text_citations(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1924 let Some(value) = value else {
1925 return Ok(());
1926 };
1927 if value.is_null() {
1928 return Ok(());
1929 }
1930 let citations = value
1931 .as_array()
1932 .ok_or(ProviderTranscriptError::InvalidItem("text citations"))?;
1933 for citation in citations {
1934 validate_anthropic_text_citation(citation)?;
1935 }
1936 Ok(())
1937}
1938
1939fn validate_anthropic_tool_search_input(
1940 name: &str,
1941 value: Option<&Value>,
1942) -> Result<(), ProviderTranscriptError> {
1943 let input = value
1944 .and_then(Value::as_object)
1945 .ok_or(ProviderTranscriptError::InvalidItem(
1946 "tool-search server input",
1947 ))?;
1948 let (query_field, maximum_chars) = match name {
1949 "tool_search_tool_regex" => ("pattern", 200usize),
1950 "tool_search_tool_bm25" => ("query", 500usize),
1951 _ => {
1952 return Err(ProviderTranscriptError::InvalidItem(
1953 "tool-search server name",
1954 ))
1955 }
1956 };
1957 reject_unknown_fields(
1958 input,
1959 &[query_field, "limit"],
1960 "tool-search server input fields",
1961 )?;
1962 let query = input.get(query_field).and_then(Value::as_str).ok_or(
1963 ProviderTranscriptError::InvalidItem("tool-search server query"),
1964 )?;
1965 if query.chars().count() > maximum_chars {
1966 return Err(ProviderTranscriptError::InvalidItem(
1967 "tool-search server query length",
1968 ));
1969 }
1970 if input.get("limit").is_some_and(|limit| {
1971 !limit
1972 .as_u64()
1973 .is_some_and(|limit| (1..=10_000).contains(&limit))
1974 }) {
1975 return Err(ProviderTranscriptError::InvalidItem(
1976 "tool-search server result limit",
1977 ));
1978 }
1979 Ok(())
1980}
1981
1982fn validate_anthropic_item(
1983 item_type: &str,
1984 origin: ProviderTranscriptOrigin,
1985 author: ProviderTranscriptAuthor,
1986 payload: &Value,
1987) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1988 let object = payload.as_object().expect("validated object");
1989 let nonempty = |field: &'static str| {
1990 object
1991 .get(field)
1992 .and_then(Value::as_str)
1993 .filter(|value| !value.trim().is_empty())
1994 .ok_or(ProviderTranscriptError::InvalidItem(field))
1995 };
1996 match item_type {
1997 "text" if origin == ProviderTranscriptOrigin::Provider => {
1998 reject_unknown_fields(object, &["type", "text", "citations"], "text block fields")?;
1999 if author != ProviderTranscriptAuthor::Model
2000 || !object.get("text").is_some_and(Value::is_string)
2001 {
2002 return Err(ProviderTranscriptError::InvalidItem("text block"));
2003 }
2004 validate_anthropic_text_citations(object.get("citations"))?;
2005 Ok(ProviderTranscriptItemKind::AnthropicText)
2006 }
2007 "thinking" if origin == ProviderTranscriptOrigin::Provider => {
2008 reject_unknown_fields(
2009 object,
2010 &["type", "thinking", "signature"],
2011 "thinking block fields",
2012 )?;
2013 if author != ProviderTranscriptAuthor::Model
2014 || !object.get("thinking").is_some_and(Value::is_string)
2015 || !object
2016 .get("signature")
2017 .and_then(Value::as_str)
2018 .is_some_and(|signature| !signature.trim().is_empty())
2019 {
2020 return Err(ProviderTranscriptError::InvalidItem("thinking block"));
2021 }
2022 Ok(ProviderTranscriptItemKind::AnthropicThinking)
2023 }
2024 "redacted_thinking" if origin == ProviderTranscriptOrigin::Provider => {
2025 reject_unknown_fields(object, &["type", "data"], "redacted thinking fields")?;
2026 if author != ProviderTranscriptAuthor::Model
2027 || !object.get("data").is_some_and(Value::is_string)
2028 {
2029 return Err(ProviderTranscriptError::InvalidItem(
2030 "redacted thinking block",
2031 ));
2032 }
2033 Ok(ProviderTranscriptItemKind::AnthropicRedactedThinking)
2034 }
2035 "server_tool_use" if origin == ProviderTranscriptOrigin::Provider => {
2036 reject_unknown_fields(
2037 object,
2038 &["type", "id", "name", "input", "caller"],
2039 "server tool use fields",
2040 )?;
2041 nonempty("id")?;
2042 let name = nonempty("name")?;
2043 if author != ProviderTranscriptAuthor::Model
2044 || !matches!(name, "tool_search_tool_regex" | "tool_search_tool_bm25")
2045 {
2046 return Err(ProviderTranscriptError::InvalidItem(
2047 "tool-search server_tool_use block",
2048 ));
2049 }
2050 validate_anthropic_tool_search_input(name, object.get("input"))?;
2051 validate_anthropic_caller(object.get("caller"))?;
2052 Ok(ProviderTranscriptItemKind::AnthropicServerToolUse)
2053 }
2054 "tool_search_tool_result" if origin == ProviderTranscriptOrigin::Provider => {
2055 reject_unknown_fields(
2056 object,
2057 &["type", "tool_use_id", "content"],
2058 "tool search result fields",
2059 )?;
2060 nonempty("tool_use_id")?;
2061 if author != ProviderTranscriptAuthor::ToolResult {
2062 return Err(ProviderTranscriptError::InvalidItem(
2063 "tool search result author",
2064 ));
2065 }
2066 validate_anthropic_search_result_content(object.get("content"))?;
2067 Ok(ProviderTranscriptItemKind::AnthropicToolSearchToolResult)
2068 }
2069 "tool_use" if origin == ProviderTranscriptOrigin::Provider => {
2070 reject_unknown_fields(
2071 object,
2072 &["type", "id", "name", "input", "caller", "toolset_name"],
2073 "tool use fields",
2074 )?;
2075 nonempty("id")?;
2076 nonempty("name")?;
2077 if author != ProviderTranscriptAuthor::Model
2078 || !object.get("input").is_some_and(Value::is_object)
2079 || object.get("toolset_name").is_some_and(|toolset| {
2080 !(toolset.is_null()
2081 || toolset.as_str().is_some_and(|name| !name.trim().is_empty()))
2082 })
2083 {
2084 return Err(ProviderTranscriptError::InvalidItem("tool_use block"));
2085 }
2086 validate_anthropic_caller(object.get("caller"))?;
2087 Ok(ProviderTranscriptItemKind::AnthropicToolUse)
2088 }
2089 "tool_result" if origin == ProviderTranscriptOrigin::HostToolSearch => {
2090 reject_unknown_fields(
2091 object,
2092 &["type", "tool_use_id", "content", "is_error"],
2093 "custom tool result fields",
2094 )?;
2095 nonempty("tool_use_id")?;
2096 let Some(content) = object.get("content").and_then(Value::as_array) else {
2097 return Err(ProviderTranscriptError::InvalidItem(
2098 "custom tool search result content",
2099 ));
2100 };
2101 if author != ProviderTranscriptAuthor::ToolResult
2102 || object
2103 .get("is_error")
2104 .is_some_and(|is_error| is_error.as_bool().is_none_or(|is_error| is_error))
2105 || content.iter().any(|reference| {
2106 let Some(reference) = reference.as_object() else {
2107 return true;
2108 };
2109 reference.len() != 2
2110 || reference.get("type").and_then(Value::as_str) != Some("tool_reference")
2111 || !reference
2112 .get("tool_name")
2113 .and_then(Value::as_str)
2114 .is_some_and(|name| !name.trim().is_empty())
2115 })
2116 {
2117 return Err(ProviderTranscriptError::InvalidItem(
2118 "custom tool references",
2119 ));
2120 }
2121 Ok(ProviderTranscriptItemKind::AnthropicToolResult)
2122 }
2123 _ => Err(ProviderTranscriptError::UnsupportedItemType),
2124 }
2125}
2126
2127fn validate_anthropic_search_result_content(
2128 content: Option<&Value>,
2129) -> Result<(), ProviderTranscriptError> {
2130 let Some(content) = content.and_then(Value::as_object) else {
2131 return Err(ProviderTranscriptError::InvalidItem(
2132 "tool search result content",
2133 ));
2134 };
2135 match content.get("type").and_then(Value::as_str) {
2136 Some("tool_search_tool_search_result") => {
2137 reject_unknown_fields(
2138 content,
2139 &["type", "tool_references"],
2140 "tool reference result fields",
2141 )?;
2142 let Some(references) = content.get("tool_references").and_then(Value::as_array) else {
2143 return Err(ProviderTranscriptError::InvalidItem("tool references"));
2144 };
2145 if references.iter().any(|reference| {
2146 let Some(reference) = reference.as_object() else {
2147 return true;
2148 };
2149 reference.len() != 2
2150 || reference.get("type").and_then(Value::as_str) != Some("tool_reference")
2151 || !reference
2152 .get("tool_name")
2153 .and_then(Value::as_str)
2154 .is_some_and(|name| !name.trim().is_empty())
2155 }) {
2156 return Err(ProviderTranscriptError::InvalidItem("tool reference"));
2157 }
2158 Ok(())
2159 }
2160 Some("tool_search_tool_result_error") => {
2161 reject_unknown_fields(
2162 content,
2163 &["type", "error_code", "error_message"],
2164 "tool search error fields",
2165 )?;
2166 if !matches!(
2167 content.get("error_code").and_then(Value::as_str),
2168 Some(
2169 "invalid_tool_input"
2170 | "unavailable"
2171 | "too_many_requests"
2172 | "execution_time_exceeded"
2173 )
2174 ) || !is_string_or_null(content.get("error_message"))
2175 {
2176 return Err(ProviderTranscriptError::InvalidItem(
2177 "tool search error shape",
2178 ));
2179 }
2180 Ok(())
2181 }
2182 _ => Err(ProviderTranscriptError::InvalidItem(
2183 "tool search result content type",
2184 )),
2185 }
2186}
2187
2188fn validated_group_identity(
2189 items: &[ProviderTranscriptItem],
2190) -> Result<(ProviderFamily, ProviderProtocol), ProviderTranscriptError> {
2191 let Some(first) = items.first() else {
2192 return Err(ProviderTranscriptError::EmptyGroup);
2193 };
2194 let family = first.family;
2195 let protocol = first.protocol;
2196 if items
2197 .iter()
2198 .any(|item| item.family != family || item.protocol != protocol)
2199 {
2200 return Err(ProviderTranscriptError::MixedProviderGroup);
2201 }
2202 if !items.iter().any(|item| item.kind.is_discovery()) {
2203 return Err(ProviderTranscriptError::MissingDiscoveryItem);
2204 }
2205 validate_group_order(protocol, items)?;
2206 Ok((family, protocol))
2207}
2208
2209fn validate_group_order(
2210 protocol: ProviderProtocol,
2211 items: &[ProviderTranscriptItem],
2212) -> Result<(), ProviderTranscriptError> {
2213 match protocol {
2214 ProviderProtocol::OpenAiResponsesV1 => {
2215 let mut seen_item_ids = HashSet::<&str>::new();
2216 let mut seen_search_call_ids = HashSet::<&str>::new();
2217 let mut seen_search_outputs = HashSet::<(&str, &str)>::new();
2218 let mut pending_provider_calls = HashSet::<&str>::new();
2219 let mut pending_client_calls = HashSet::<&str>::new();
2220 let mut pending_unkeyed_provider_calls = 0usize;
2221 let mut has_client_search_call = false;
2222 let mut has_client_search_output = false;
2223 let mut loaded_at = HashMap::<String, usize>::new();
2224 let mut function_calls = Vec::<(usize, &str)>::new();
2225 for (index, item) in items.iter().enumerate() {
2226 if let Some(id) = item.payload.get("id").and_then(Value::as_str) {
2227 if !seen_item_ids.insert(id) {
2228 return Err(ProviderTranscriptError::InvalidGroupOrder);
2229 }
2230 }
2231 match item.kind {
2232 ProviderTranscriptItemKind::OpenAiToolSearchCall => {
2233 let execution = item
2234 .payload
2235 .get("execution")
2236 .and_then(Value::as_str)
2237 .unwrap_or_default();
2238 let call_id = item
2239 .payload
2240 .get("call_id")
2241 .and_then(Value::as_str)
2242 .unwrap_or_default();
2243 if call_id.is_empty() {
2244 if execution == "server" {
2245 pending_unkeyed_provider_calls =
2246 pending_unkeyed_provider_calls.saturating_add(1);
2247 } else {
2248 return Err(ProviderTranscriptError::InvalidGroupOrder);
2249 }
2250 } else {
2251 if !seen_search_call_ids.insert(call_id) {
2252 return Err(ProviderTranscriptError::InvalidGroupOrder);
2253 }
2254 if execution == "server" {
2255 pending_provider_calls.insert(call_id);
2256 } else {
2257 pending_client_calls.insert(call_id);
2258 }
2259 }
2260 has_client_search_call |= execution == "client";
2261 }
2262 ProviderTranscriptItemKind::OpenAiToolSearchOutput => {
2263 let execution = item
2264 .payload
2265 .get("execution")
2266 .and_then(Value::as_str)
2267 .unwrap_or_default();
2268 let call_id = item
2269 .payload
2270 .get("call_id")
2271 .and_then(Value::as_str)
2272 .unwrap_or_default();
2273 if !call_id.is_empty() && !seen_search_outputs.insert((execution, call_id))
2274 {
2275 return Err(ProviderTranscriptError::InvalidGroupOrder);
2276 }
2277 let matched_pending = if call_id.is_empty() {
2278 if execution != "server" || pending_unkeyed_provider_calls == 0 {
2279 false
2280 } else {
2281 pending_unkeyed_provider_calls =
2282 pending_unkeyed_provider_calls.saturating_sub(1);
2283 true
2284 }
2285 } else {
2286 let pending = if execution == "server" {
2287 &mut pending_provider_calls
2288 } else {
2289 &mut pending_client_calls
2290 };
2291 pending.remove(call_id)
2292 };
2293 has_client_search_output |= execution == "client";
2294 if !matched_pending
2295 && (execution == "server"
2296 || item.origin != ProviderTranscriptOrigin::HostToolSearch)
2297 {
2298 return Err(ProviderTranscriptError::InvalidGroupOrder);
2303 }
2304 for name in openai_loaded_tool_names(&item.payload) {
2305 loaded_at.entry(name).or_insert(index);
2306 }
2307 }
2308 ProviderTranscriptItemKind::OpenAiFunctionCall => {
2309 let name = item
2310 .payload
2311 .get("name")
2312 .and_then(Value::as_str)
2313 .unwrap_or_default();
2314 function_calls.push((index, name));
2315 }
2316 _ => {}
2317 }
2318 }
2319 if !pending_provider_calls.is_empty() || pending_unkeyed_provider_calls != 0 {
2320 return Err(ProviderTranscriptError::InvalidGroupOrder);
2321 }
2322 if has_client_search_call && (has_client_search_output || !function_calls.is_empty()) {
2328 return Err(ProviderTranscriptError::InvalidGroupOrder);
2329 }
2330 let mut matched_loaded_call = false;
2331 for (index, name) in &function_calls {
2332 if let Some(output_index) = loaded_at.get(*name) {
2333 if index <= output_index {
2334 return Err(ProviderTranscriptError::InvalidGroupOrder);
2335 }
2336 matched_loaded_call = true;
2337 }
2338 }
2339 if !loaded_at.is_empty() && !function_calls.is_empty() && !matched_loaded_call {
2340 return Err(ProviderTranscriptError::InvalidGroupOrder);
2341 }
2342 }
2343 ProviderProtocol::AnthropicMessages2023_06_01 => {
2344 let mut server_ids = HashSet::new();
2345 let mut all_tool_use_ids = HashSet::new();
2346 let mut completed_server_ids = HashSet::new();
2347 let mut referenced_at = HashMap::<String, usize>::new();
2348 let mut tool_uses = Vec::<(usize, &str)>::new();
2349 let mut thinking_blocks = 0usize;
2350 for (index, item) in items.iter().enumerate() {
2351 match item.kind {
2352 ProviderTranscriptItemKind::AnthropicThinking
2353 | ProviderTranscriptItemKind::AnthropicRedactedThinking => {
2354 thinking_blocks = thinking_blocks.saturating_add(1);
2355 if index != 0 || thinking_blocks != 1 {
2356 return Err(ProviderTranscriptError::InvalidGroupOrder);
2357 }
2358 }
2359 ProviderTranscriptItemKind::AnthropicServerToolUse => {
2360 if let Some(id) = item.payload.get("id").and_then(Value::as_str) {
2361 if !server_ids.insert(id.to_string())
2362 || !all_tool_use_ids.insert(id.to_string())
2363 {
2364 return Err(ProviderTranscriptError::InvalidGroupOrder);
2365 }
2366 }
2367 }
2368 ProviderTranscriptItemKind::AnthropicToolSearchToolResult => {
2369 let tool_use_id = item
2370 .payload
2371 .get("tool_use_id")
2372 .and_then(Value::as_str)
2373 .unwrap_or_default();
2374 if !server_ids.contains(tool_use_id)
2375 || !completed_server_ids.insert(tool_use_id.to_string())
2376 {
2377 return Err(ProviderTranscriptError::InvalidGroupOrder);
2378 }
2379 if let Some(references) = item
2380 .payload
2381 .get("content")
2382 .and_then(|content| content.get("tool_references"))
2383 .and_then(Value::as_array)
2384 {
2385 for reference in references {
2386 if let Some(name) =
2387 reference.get("tool_name").and_then(Value::as_str)
2388 {
2389 referenced_at.entry(name.to_string()).or_insert(index);
2390 }
2391 }
2392 }
2393 }
2394 ProviderTranscriptItemKind::AnthropicToolUse => {
2395 let id = item
2396 .payload
2397 .get("id")
2398 .and_then(Value::as_str)
2399 .unwrap_or_default();
2400 if !all_tool_use_ids.insert(id.to_string()) {
2401 return Err(ProviderTranscriptError::InvalidGroupOrder);
2402 }
2403 let name = item
2404 .payload
2405 .get("name")
2406 .and_then(Value::as_str)
2407 .unwrap_or_default();
2408 tool_uses.push((index, name));
2409 }
2410 _ => {}
2411 }
2412 }
2413 if completed_server_ids.len() != server_ids.len() {
2414 return Err(ProviderTranscriptError::InvalidGroupOrder);
2415 }
2416 let mut matched_reference_use = false;
2417 for (index, name) in &tool_uses {
2418 if let Some(result_index) = referenced_at.get(*name) {
2419 if index <= result_index {
2420 return Err(ProviderTranscriptError::InvalidGroupOrder);
2421 }
2422 matched_reference_use = true;
2423 }
2424 }
2425 if !referenced_at.is_empty() && !tool_uses.is_empty() && !matched_reference_use {
2426 return Err(ProviderTranscriptError::InvalidGroupOrder);
2427 }
2428 }
2429 }
2430 Ok(())
2431}
2432
2433fn openai_loaded_tool_names(payload: &Value) -> Vec<String> {
2434 let Some(tools) = payload.get("tools").and_then(Value::as_array) else {
2435 return Vec::new();
2436 };
2437 let mut names = Vec::new();
2438 for tool in tools {
2439 match tool.get("type").and_then(Value::as_str) {
2440 Some("function") => {
2441 if let Some(name) = tool.get("name").and_then(Value::as_str) {
2442 names.push(name.to_string());
2443 }
2444 }
2445 Some("namespace") => {
2446 let namespace = tool.get("name").and_then(Value::as_str).unwrap_or_default();
2447 if let Some(functions) = tool.get("tools").and_then(Value::as_array) {
2448 for function in functions {
2449 if let Some(name) = function.get("name").and_then(Value::as_str) {
2450 names.push(name.to_string());
2451 names.push(format!("{namespace}.{name}"));
2452 }
2453 }
2454 }
2455 }
2456 _ => {}
2457 }
2458 }
2459 names
2460}
2461
2462pub fn validated_openai_loaded_tool_names<'a>(
2466 groups: impl IntoIterator<Item = &'a ProviderTranscriptGroup>,
2467 family: ProviderFamily,
2468) -> Vec<String> {
2469 if !matches!(family, ProviderFamily::OpenAi | ProviderFamily::Copilot) {
2470 return Vec::new();
2471 }
2472 let mut names = groups
2473 .into_iter()
2474 .filter(|group| {
2475 group.family() == family
2476 && group.protocol() == ProviderProtocol::OpenAiResponsesV1
2477 && ProviderTranscriptGroup::validate_items(group.items()).is_ok()
2478 })
2479 .flat_map(ProviderTranscriptGroup::items)
2480 .filter(|item| item.kind() == ProviderTranscriptItemKind::OpenAiToolSearchOutput)
2481 .flat_map(|item| openai_loaded_tool_names(item.payload()))
2482 .collect::<Vec<_>>();
2483 names.sort();
2484 names.dedup();
2485 names
2486}
2487
2488fn stable_item_id(
2489 family: ProviderFamily,
2490 protocol: ProviderProtocol,
2491 origin: ProviderTranscriptOrigin,
2492 author: ProviderTranscriptAuthor,
2493 kind: ProviderTranscriptItemKind,
2494 payload: &Value,
2495) -> Result<String, ProviderTranscriptError> {
2496 if !payload.is_object() {
2497 return Err(ProviderTranscriptError::PayloadNotObject);
2498 }
2499 let identity = serde_json::json!({
2500 "family": family,
2501 "protocol": protocol,
2502 "origin": origin,
2503 "author": author,
2504 "kind": kind,
2505 "payload": payload,
2506 });
2507 let digest = hash_json(ITEM_HASH_DOMAIN, &identity);
2508 Ok(format!("pti_{kind:?}_{digest}"))
2511}
2512
2513fn stable_group_id(
2514 epoch: u64,
2515 family: ProviderFamily,
2516 protocol: ProviderProtocol,
2517 provider_boundary_sha256: &str,
2518 anchor_message_id: &str,
2519 _id_hint: Option<&str>,
2520 items: &[ProviderTranscriptItem],
2521) -> String {
2522 let mut hasher = Sha256::new();
2523 hasher.update(GROUP_HASH_DOMAIN);
2524 hasher.update(epoch.to_be_bytes());
2525 hasher.update([0]);
2526 hasher.update(serde_json::to_vec(&family).unwrap_or_default());
2527 hasher.update([0]);
2528 hasher.update(serde_json::to_vec(&protocol).unwrap_or_default());
2529 hasher.update([0]);
2530 hasher.update(provider_boundary_sha256.as_bytes());
2531 hasher.update([0]);
2532 hasher.update(anchor_message_id.as_bytes());
2533 hasher.update([0]);
2534 for item in items {
2535 hasher.update([0]);
2536 hasher.update(item.id.as_bytes());
2537 }
2538 format!("ptg_{}", hex::encode(hasher.finalize()))
2539}
2540
2541fn hash_json(domain: &[u8], value: &Value) -> String {
2542 hash_bytes(
2543 domain,
2544 &serde_json::to_vec(&canonical_json(value)).unwrap_or_default(),
2545 )
2546}
2547
2548fn canonical_json(value: &Value) -> Value {
2549 match value {
2550 Value::Object(object) => {
2551 let mut entries = object.iter().collect::<Vec<_>>();
2552 entries.sort_by_key(|(key, _)| *key);
2553 Value::Object(
2554 entries
2555 .into_iter()
2556 .map(|(key, value)| (key.clone(), canonical_json(value)))
2557 .collect(),
2558 )
2559 }
2560 Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()),
2561 _ => value.clone(),
2562 }
2563}
2564
2565fn hash_bytes(domain: &[u8], bytes: &[u8]) -> String {
2566 let mut hasher = Sha256::new();
2567 hasher.update(domain);
2568 hasher.update(bytes);
2569 hex::encode(hasher.finalize())
2570}
2571
2572#[cfg(test)]
2573mod tests {
2574 use serde_json::json;
2575
2576 use super::*;
2577 use crate::Message;
2578
2579 fn test_boundary(provider_name: &str, provider_type: &str) -> String {
2580 provider_transcript_boundary_sha256(Some(provider_name), Some(provider_type)).unwrap()
2581 }
2582
2583 fn openai_boundary() -> String {
2584 test_boundary("openai-test", "openai")
2585 }
2586
2587 fn anthropic_boundary() -> String {
2588 test_boundary("anthropic-test", "anthropic")
2589 }
2590
2591 fn activate_openai(session: &mut Session) {
2592 session
2593 .activate_provider_transcript_route(
2594 ProviderFamily::OpenAi,
2595 ProviderProtocol::OpenAiResponsesV1,
2596 &openai_boundary(),
2597 )
2598 .unwrap();
2599 }
2600
2601 fn activate_anthropic(session: &mut Session) -> bool {
2602 session
2603 .activate_provider_transcript_route(
2604 ProviderFamily::Anthropic,
2605 ProviderProtocol::AnthropicMessages2023_06_01,
2606 &anthropic_boundary(),
2607 )
2608 .unwrap()
2609 }
2610
2611 fn replayable_openai(session: &Session) -> Vec<&ProviderTranscriptGroup> {
2612 let boundary = session
2613 .provider_transcript
2614 .active_provider_boundary_sha256()
2615 .expect("provider transcript test route must be active");
2616 session.provider_transcript.replayable_groups(
2617 ProviderFamily::OpenAi,
2618 ProviderProtocol::OpenAiResponsesV1,
2619 boundary,
2620 )
2621 }
2622
2623 fn test_group(
2624 anchor_message_id: impl Into<String>,
2625 items: Vec<ProviderTranscriptItem>,
2626 ) -> Result<ProviderTranscriptGroup, ProviderTranscriptError> {
2627 ProviderTranscriptGroup::new(
2628 0,
2629 0,
2630 anchor_message_id.into(),
2631 None,
2632 unbound_provider_boundary_sha256(),
2633 items,
2634 )
2635 }
2636
2637 fn openai_output_items() -> Vec<ProviderTranscriptItem> {
2638 [
2639 (
2640 ProviderTranscriptAuthor::Model,
2641 json!({
2642 "type": "tool_search_call",
2643 "id": "tsc_1",
2644 "execution": "server",
2645 "call_id": "search_1",
2646 "status": "completed",
2647 "arguments": {"paths": ["crm"]}
2648 }),
2649 ),
2650 (
2651 ProviderTranscriptAuthor::ToolResult,
2652 json!({
2653 "type": "tool_search_output",
2654 "id": "tso_1",
2655 "execution": "server",
2656 "call_id": "search_1",
2657 "status": "completed",
2658 "tools": [{"type": "function", "name": "list_open_orders"}]
2659 }),
2660 ),
2661 (
2662 ProviderTranscriptAuthor::Model,
2663 json!({
2664 "type": "function_call",
2665 "call_id": "call_abc123",
2666 "name": "list_open_orders",
2667 "arguments": "{\"customer_id\":\"CUST-12345\"}"
2668 }),
2669 ),
2670 ]
2671 .into_iter()
2672 .map(|(author, payload)| {
2673 ProviderTranscriptItem::try_from_payload(
2674 ProviderFamily::OpenAi,
2675 ProviderProtocol::OpenAiResponsesV1,
2676 ProviderTranscriptOrigin::Provider,
2677 author,
2678 payload,
2679 )
2680 .unwrap()
2681 })
2682 .collect()
2683 }
2684
2685 fn anthropic_items() -> Vec<ProviderTranscriptItem> {
2686 [
2687 (
2688 ProviderTranscriptAuthor::Model,
2689 json!({"type":"text","text":"I will search."}),
2690 ),
2691 (
2692 ProviderTranscriptAuthor::Model,
2693 json!({
2694 "type":"server_tool_use",
2695 "id":"srvtoolu_01ABC123",
2696 "name":"tool_search_tool_regex",
2697 "input":{"pattern":"weather"}
2698 }),
2699 ),
2700 (
2701 ProviderTranscriptAuthor::ToolResult,
2702 json!({
2703 "type":"tool_search_tool_result",
2704 "tool_use_id":"srvtoolu_01ABC123",
2705 "content":{
2706 "type":"tool_search_tool_search_result",
2707 "tool_references":[
2708 {"type":"tool_reference","tool_name":"get_weather"}
2709 ]
2710 }
2711 }),
2712 ),
2713 (
2714 ProviderTranscriptAuthor::Model,
2715 json!({
2716 "type":"tool_use",
2717 "id":"toolu_01XYZ789",
2718 "name":"get_weather",
2719 "input":{"location":"San Francisco"}
2720 }),
2721 ),
2722 ]
2723 .into_iter()
2724 .map(|(author, payload)| {
2725 ProviderTranscriptItem::try_from_payload(
2726 ProviderFamily::Anthropic,
2727 ProviderProtocol::AnthropicMessages2023_06_01,
2728 ProviderTranscriptOrigin::Provider,
2729 author,
2730 payload,
2731 )
2732 .unwrap()
2733 })
2734 .collect()
2735 }
2736
2737 fn assert_payload_rejected_without_leak(
2738 family: ProviderFamily,
2739 protocol: ProviderProtocol,
2740 origin: ProviderTranscriptOrigin,
2741 author: ProviderTranscriptAuthor,
2742 payload: Value,
2743 ) {
2744 let error =
2745 ProviderTranscriptItem::try_from_payload(family, protocol, origin, author, payload)
2746 .expect_err("payload must fail closed");
2747 let diagnostic = format!("{error:?} {error}");
2748 assert!(!diagnostic.contains("UNKNOWN_FIELD_SENTINEL"));
2749 }
2750
2751 fn assert_unknown_top_level_rejected(item: &ProviderTranscriptItem) {
2752 let mut payload = item.payload().clone();
2753 payload
2754 .as_object_mut()
2755 .unwrap()
2756 .insert("unknown".to_string(), json!("UNKNOWN_FIELD_SENTINEL"));
2757 assert_payload_rejected_without_leak(
2758 item.family(),
2759 item.protocol(),
2760 item.origin(),
2761 item.author(),
2762 payload,
2763 );
2764 }
2765
2766 #[test]
2767 fn openai_search_items_round_trip_in_exact_order() {
2768 let items = openai_output_items();
2769 let encoded = serde_json::to_string(&items).unwrap();
2770 let decoded: Vec<ProviderTranscriptItem> = serde_json::from_str(&encoded).unwrap();
2771 assert_eq!(decoded, items);
2772 assert_eq!(
2773 decoded
2774 .iter()
2775 .map(ProviderTranscriptItem::kind)
2776 .collect::<Vec<_>>(),
2777 vec![
2778 ProviderTranscriptItemKind::OpenAiToolSearchCall,
2779 ProviderTranscriptItemKind::OpenAiToolSearchOutput,
2780 ProviderTranscriptItemKind::OpenAiFunctionCall,
2781 ]
2782 );
2783 }
2784
2785 #[test]
2786 fn openai_provider_output_preserves_official_nullable_fields() {
2787 let payloads = [
2788 (
2789 ProviderTranscriptAuthor::Model,
2790 json!({
2791 "type":"reasoning","id":"rs_nullable","summary":[],
2792 "agent":null,"content":null,"encrypted_content":null,"status":null
2793 }),
2794 ),
2795 (
2796 ProviderTranscriptAuthor::Model,
2797 json!({
2798 "type":"message","id":"msg_nullable","role":"assistant",
2799 "status":"completed","agent":null,"phase":null,
2800 "content":[{
2801 "type":"output_text","text":"Searching",
2802 "annotations":[],"logprobs":null
2803 }]
2804 }),
2805 ),
2806 (
2807 ProviderTranscriptAuthor::Model,
2808 json!({
2809 "type":"tool_search_call","id":"tsc_nullable","execution":"server",
2810 "call_id":null,"status":"completed","arguments":{"query":"orders"},
2811 "agent":null,"created_by":null
2812 }),
2813 ),
2814 (
2815 ProviderTranscriptAuthor::ToolResult,
2816 json!({
2817 "type":"tool_search_output","id":"tso_nullable","execution":"server",
2818 "call_id":null,"status":"completed","agent":null,"created_by":null,
2819 "tools":[{"type":"function","name":"get_orders"}]
2820 }),
2821 ),
2822 (
2823 ProviderTranscriptAuthor::Model,
2824 json!({
2825 "type":"function_call","id":null,"call_id":"call_nullable",
2826 "name":"get_orders","arguments":"{}","agent":null,"caller":null,
2827 "namespace":null,"status":null,"created_by":null
2828 }),
2829 ),
2830 ];
2831 let expected = payloads
2832 .iter()
2833 .map(|(_, payload)| payload.clone())
2834 .collect::<Vec<_>>();
2835 let items = payloads
2836 .into_iter()
2837 .map(|(author, payload)| {
2838 ProviderTranscriptItem::try_from_payload(
2839 ProviderFamily::OpenAi,
2840 ProviderProtocol::OpenAiResponsesV1,
2841 ProviderTranscriptOrigin::Provider,
2842 author,
2843 payload,
2844 )
2845 .unwrap()
2846 })
2847 .collect::<Vec<_>>();
2848
2849 ProviderTranscriptGroup::validate_items(&items).unwrap();
2850 assert_eq!(
2851 items
2852 .iter()
2853 .map(|item| item.payload().clone())
2854 .collect::<Vec<_>>(),
2855 expected
2856 );
2857 }
2858
2859 #[test]
2860 fn anthropic_reference_chain_round_trips_without_schema_expansion() {
2861 let items = anthropic_items();
2862 let encoded = serde_json::to_value(&items).unwrap();
2863 let decoded: Vec<ProviderTranscriptItem> = serde_json::from_value(encoded).unwrap();
2864 assert_eq!(decoded, items);
2865 assert_eq!(
2866 decoded[2].payload()["content"]["tool_references"][0],
2867 json!({"type":"tool_reference","tool_name":"get_weather"})
2868 );
2869 }
2870
2871 #[test]
2872 fn client_output_and_additional_tools_are_position_safe_variants() {
2873 let client = ProviderTranscriptItem::try_from_payload(
2874 ProviderFamily::OpenAi,
2875 ProviderProtocol::OpenAiResponsesV1,
2876 ProviderTranscriptOrigin::HostToolSearch,
2877 ProviderTranscriptAuthor::ToolResult,
2878 json!({
2879 "type":"tool_search_output",
2880 "execution":"client",
2881 "call_id":"call_abc123",
2882 "status":"completed",
2883 "tools":[]
2884 }),
2885 )
2886 .unwrap();
2887 let additional = ProviderTranscriptItem::try_from_payload(
2888 ProviderFamily::OpenAi,
2889 ProviderProtocol::OpenAiResponsesV1,
2890 ProviderTranscriptOrigin::DeveloperContext,
2891 ProviderTranscriptAuthor::Host,
2892 json!({"type":"additional_tools","role":"developer","tools":[]}),
2893 )
2894 .unwrap();
2895 assert_eq!(
2896 client.kind(),
2897 ProviderTranscriptItemKind::OpenAiToolSearchOutput
2898 );
2899 assert_eq!(
2900 additional.kind(),
2901 ProviderTranscriptItemKind::OpenAiAdditionalTools
2902 );
2903 ProviderTranscriptGroup::validate_items(std::slice::from_ref(&client))
2904 .expect("a client output is a standalone host-owned continuation group");
2905 }
2906
2907 #[test]
2908 fn openai_loaded_names_survive_resume_and_come_only_from_search_output() {
2909 let mut session = Session::new("client-loaded-resume", "gpt-5.6");
2910 activate_openai(&mut session);
2911 let assistant = Message::assistant("", None);
2912 let anchor = assistant.id.clone();
2913 session.add_message(assistant);
2914 let call = ProviderTranscriptItem::try_from_payload(
2915 ProviderFamily::OpenAi,
2916 ProviderProtocol::OpenAiResponsesV1,
2917 ProviderTranscriptOrigin::Provider,
2918 ProviderTranscriptAuthor::Model,
2919 json!({
2920 "type":"tool_search_call","id":"tsc_resume","execution":"client",
2921 "call_id":"search_resume","status":"completed",
2922 "arguments":{"query":"files"}
2923 }),
2924 )
2925 .unwrap();
2926 session
2927 .append_provider_transcript_group(&anchor, None, vec![call])
2928 .unwrap();
2929 let output = ProviderTranscriptItem::try_from_payload(
2930 ProviderFamily::OpenAi,
2931 ProviderProtocol::OpenAiResponsesV1,
2932 ProviderTranscriptOrigin::HostToolSearch,
2933 ProviderTranscriptAuthor::ToolResult,
2934 json!({
2935 "type":"tool_search_output","execution":"client",
2936 "call_id":"search_resume","status":"completed","tools":[{
2937 "type":"function","name":"Glob","description":"Find files",
2938 "parameters":{"type":"object"},"strict":false,"defer_loading":true
2939 }]
2940 }),
2941 )
2942 .unwrap();
2943 session
2944 .append_provider_transcript_group(&anchor, None, vec![output])
2945 .unwrap();
2946
2947 let names =
2948 validated_openai_loaded_tool_names(replayable_openai(&session), ProviderFamily::OpenAi);
2949 assert_eq!(names, vec!["Glob"]);
2950
2951 let resumed: Session =
2952 serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
2953 let resumed_names =
2954 validated_openai_loaded_tool_names(replayable_openai(&resumed), ProviderFamily::OpenAi);
2955 assert_eq!(resumed_names, vec!["Glob"]);
2956
2957 let mut hosted = Session::new("hosted-loaded", "gpt-5.6");
2958 activate_openai(&mut hosted);
2959 let assistant = Message::assistant("normalized", None);
2960 let anchor = assistant.id.clone();
2961 hosted.add_message(assistant);
2962 hosted
2963 .append_provider_transcript_group(&anchor, None, openai_output_items())
2964 .unwrap();
2965 assert_eq!(
2966 validated_openai_loaded_tool_names(replayable_openai(&hosted), ProviderFamily::OpenAi,),
2967 vec!["list_open_orders"],
2968 "the following ordinary function_call must not add loading state"
2969 );
2970 }
2971
2972 #[test]
2973 fn openai_loaded_definitions_distinguish_provider_output_from_host_input() {
2974 for definition in [
2975 json!({"type":"function","name":"get_weather"}),
2976 json!({
2977 "type":"function","name":"get_weather",
2978 "parameters":null,"strict":null,"allowed_callers":null,
2979 "defer_loading":null,"description":null,"output_schema":null
2980 }),
2981 ] {
2982 ProviderTranscriptItem::try_from_payload(
2983 ProviderFamily::OpenAi,
2984 ProviderProtocol::OpenAiResponsesV1,
2985 ProviderTranscriptOrigin::Provider,
2986 ProviderTranscriptAuthor::ToolResult,
2987 json!({
2988 "type":"tool_search_output","id":"tso_provider","execution":"server",
2989 "call_id":"search_provider","status":"completed","tools":[definition]
2990 }),
2991 )
2992 .expect("provider output permits omitted or nullable optional definition fields");
2993 }
2994
2995 let host_positions = |definition: Value| {
2996 [
2997 (
2998 ProviderTranscriptOrigin::HostToolSearch,
2999 ProviderTranscriptAuthor::ToolResult,
3000 json!({
3001 "type":"tool_search_output","execution":"client",
3002 "call_id":"search_host","status":"completed",
3003 "tools":[definition.clone()]
3004 }),
3005 ),
3006 (
3007 ProviderTranscriptOrigin::DeveloperContext,
3008 ProviderTranscriptAuthor::Host,
3009 json!({
3010 "type":"additional_tools","role":"developer","tools":[definition]
3011 }),
3012 ),
3013 ]
3014 };
3015
3016 for definition in [
3017 json!({
3018 "type":"function","name":"workflow_run",
3019 "parameters":null,"strict":null
3020 }),
3021 json!({
3022 "type":"function","name":"workflow_run",
3023 "parameters":{"type":"object"},"strict":false
3024 }),
3025 ] {
3026 for (origin, author, payload) in host_positions(definition) {
3027 ProviderTranscriptItem::try_from_payload(
3028 ProviderFamily::OpenAi,
3029 ProviderProtocol::OpenAiResponsesV1,
3030 origin,
3031 author,
3032 payload,
3033 )
3034 .expect("host input requires both keys and permits nullable values");
3035 }
3036 }
3037
3038 for definition in [
3039 json!({"type":"function","name":"workflow_run","strict":null}),
3040 json!({
3041 "type":"function","name":"workflow_run",
3042 "parameters":{"type":"object"}
3043 }),
3044 json!({
3045 "type":"function","name":"workflow_run",
3046 "parameters":[],"strict":null
3047 }),
3048 json!({
3049 "type":"function","name":"workflow_run",
3050 "parameters":null,"strict":"false"
3051 }),
3052 ] {
3053 for (origin, author, payload) in host_positions(definition) {
3054 assert!(ProviderTranscriptItem::try_from_payload(
3055 ProviderFamily::OpenAi,
3056 ProviderProtocol::OpenAiResponsesV1,
3057 origin,
3058 author,
3059 payload,
3060 )
3061 .is_err());
3062 }
3063 }
3064
3065 let scoped = ProviderTranscriptItem::try_from_payload(
3066 ProviderFamily::OpenAi,
3067 ProviderProtocol::OpenAiResponsesV1,
3068 ProviderTranscriptOrigin::HostToolSearch,
3069 ProviderTranscriptAuthor::ToolResult,
3070 json!({
3071 "type":"tool_search_output","execution":"client","call_id":"search_scoped",
3072 "status":"completed","tools":[{
3073 "type":"function","name":"load_skill","defer_loading":true,
3074 "strict":false,
3075 "parameters":{
3076 "type":"object",
3077 "properties":{"skill_id":{"type":"string","const":"skill:pinned@rev-7"}},
3078 "required":["skill_id"],"additionalProperties":false
3079 }
3080 }]
3081 }),
3082 )
3083 .expect("host input with a complete pinned parameter schema is valid");
3084 let encoded = serde_json::to_value(&scoped).unwrap();
3085 assert_eq!(
3086 serde_json::from_value::<ProviderTranscriptItem>(encoded).unwrap(),
3087 scoped
3088 );
3089
3090 for nested in [
3091 json!({"type":"function","name":"lookup"}),
3092 json!({"type":"function","name":"lookup","parameters":null,"strict":null}),
3093 ] {
3094 ProviderTranscriptItem::try_from_payload(
3095 ProviderFamily::OpenAi,
3096 ProviderProtocol::OpenAiResponsesV1,
3097 ProviderTranscriptOrigin::DeveloperContext,
3098 ProviderTranscriptAuthor::Host,
3099 json!({
3100 "type":"additional_tools","role":"developer","tools":[{
3101 "type":"namespace","name":"crm","description":"CRM tools","tools":[nested]
3102 }]
3103 }),
3104 )
3105 .expect("namespace member parameters remain optional and nullable");
3106 }
3107 }
3108
3109 #[test]
3110 fn anthropic_builtin_search_inputs_are_name_typed_and_bounded() {
3111 let item = |name: &str, input: Value| {
3112 ProviderTranscriptItem::try_from_payload(
3113 ProviderFamily::Anthropic,
3114 ProviderProtocol::AnthropicMessages2023_06_01,
3115 ProviderTranscriptOrigin::Provider,
3116 ProviderTranscriptAuthor::Model,
3117 json!({
3118 "type":"server_tool_use","id":"srvtoolu_boundary","name":name,"input":input
3119 }),
3120 )
3121 };
3122
3123 for input in [
3124 json!({"pattern":"x".repeat(200),"limit":1}),
3125 json!({"pattern":"weather","limit":10_000}),
3126 ] {
3127 item("tool_search_tool_regex", input).expect("regex boundary should be valid");
3128 }
3129 for input in [
3130 json!({"query":"x".repeat(500),"limit":1}),
3131 json!({"query":"weather tools","limit":10_000}),
3132 ] {
3133 item("tool_search_tool_bm25", input).expect("BM25 boundary should be valid");
3134 }
3135
3136 let invalid = [
3137 ("tool_search_tool_regex", json!({})),
3138 ("tool_search_tool_regex", json!({"pattern":17})),
3139 ("tool_search_tool_regex", json!({"pattern":"x".repeat(201)})),
3140 ("tool_search_tool_regex", json!({"query":"wrong key"})),
3141 (
3142 "tool_search_tool_regex",
3143 json!({"pattern":"ok","credential":"SEARCH_INPUT_SENTINEL"}),
3144 ),
3145 ("tool_search_tool_bm25", json!({})),
3146 ("tool_search_tool_bm25", json!({"query":17})),
3147 ("tool_search_tool_bm25", json!({"query":"x".repeat(501)})),
3148 ("tool_search_tool_bm25", json!({"pattern":"wrong key"})),
3149 ("tool_search_tool_bm25", json!({"query":"ok","limit":0})),
3150 (
3151 "tool_search_tool_bm25",
3152 json!({"query":"ok","limit":10_001}),
3153 ),
3154 ("tool_search_tool_bm25", json!({"query":"ok","limit":1.5})),
3155 ("tool_search_tool_bm25", json!({"query":"ok","limit":"1"})),
3156 ];
3157 for (name, input) in invalid {
3158 let error = item(name, input).expect_err("invalid search input must fail closed");
3159 let diagnostic = format!("{error:?} {error}");
3160 assert!(!diagnostic.contains("SEARCH_INPUT_SENTINEL"));
3161 }
3162 }
3163
3164 #[test]
3165 fn unsupported_or_malformed_items_fail_closed() {
3166 let unsupported = ProviderTranscriptItem::try_from_payload(
3167 ProviderFamily::OpenAi,
3168 ProviderProtocol::OpenAiResponsesV1,
3169 ProviderTranscriptOrigin::Provider,
3170 ProviderTranscriptAuthor::Model,
3171 json!({"type":"arbitrary_json","secret":"do-not-replay"}),
3172 );
3173 assert!(matches!(
3174 unsupported,
3175 Err(ProviderTranscriptError::UnsupportedItemType)
3176 ));
3177
3178 let malformed = ProviderTranscriptItem::try_from_payload(
3179 ProviderFamily::Anthropic,
3180 ProviderProtocol::AnthropicMessages2023_06_01,
3181 ProviderTranscriptOrigin::Provider,
3182 ProviderTranscriptAuthor::ToolResult,
3183 json!({
3184 "type":"tool_search_tool_result",
3185 "tool_use_id":"srvtoolu_1",
3186 "content":{"type":"tool_search_tool_search_result","tool_references":[
3187 {"type":"not_a_reference","tool_name":"danger"}
3188 ]}
3189 }),
3190 );
3191 assert!(malformed.is_err());
3192
3193 let mut reversed = openai_output_items();
3194 reversed.swap(0, 1);
3195 let group = test_group("anchor", reversed);
3196 assert_eq!(
3197 group.unwrap_err(),
3198 ProviderTranscriptError::InvalidGroupOrder
3199 );
3200 }
3201
3202 #[test]
3203 fn nested_capability_and_content_shapes_fail_closed_without_payload_leaks() {
3204 let cases = [
3205 ProviderTranscriptItem::try_from_payload(
3206 ProviderFamily::OpenAi,
3207 ProviderProtocol::OpenAiResponsesV1,
3208 ProviderTranscriptOrigin::Provider,
3209 ProviderTranscriptAuthor::Model,
3210 json!({"type":"reasoning","secret":"REASONING_SENTINEL"}),
3211 ),
3212 ProviderTranscriptItem::try_from_payload(
3213 ProviderFamily::OpenAi,
3214 ProviderProtocol::OpenAiResponsesV1,
3215 ProviderTranscriptOrigin::Provider,
3216 ProviderTranscriptAuthor::ToolResult,
3217 json!({
3218 "type":"tool_search_output","id":"tso_invalid","execution":"server",
3219 "call_id":"search_invalid",
3220 "status":"completed","tools":[42],"secret":"TOOLS_SENTINEL"
3221 }),
3222 ),
3223 ProviderTranscriptItem::try_from_payload(
3224 ProviderFamily::OpenAi,
3225 ProviderProtocol::OpenAiResponsesV1,
3226 ProviderTranscriptOrigin::DeveloperContext,
3227 ProviderTranscriptAuthor::Host,
3228 json!({
3229 "type":"additional_tools","role":"developer",
3230 "tools":[{"type":"future_capability","secret":"CAP_SENTINEL"}]
3231 }),
3232 ),
3233 ProviderTranscriptItem::try_from_payload(
3234 ProviderFamily::OpenAi,
3235 ProviderProtocol::OpenAiResponsesV1,
3236 ProviderTranscriptOrigin::Provider,
3237 ProviderTranscriptAuthor::Model,
3238 json!({
3239 "type":"message","id":"msg_invalid","role":"assistant","status":"completed",
3240 "content":[{"type":"future_content","secret":"CONTENT_SENTINEL"}]
3241 }),
3242 ),
3243 ProviderTranscriptItem::try_from_payload(
3244 ProviderFamily::Anthropic,
3245 ProviderProtocol::AnthropicMessages2023_06_01,
3246 ProviderTranscriptOrigin::Provider,
3247 ProviderTranscriptAuthor::Model,
3248 json!({"type":"thinking","thinking":"private","secret":"THINK_SENTINEL"}),
3249 ),
3250 ];
3251 for result in cases {
3252 let error = result.expect_err("unsupported nested shapes must fail closed");
3253 let diagnostic = format!("{error:?} {error}");
3254 for sentinel in [
3255 "REASONING_SENTINEL",
3256 "TOOLS_SENTINEL",
3257 "CAP_SENTINEL",
3258 "CONTENT_SENTINEL",
3259 "THINK_SENTINEL",
3260 ] {
3261 assert!(!diagnostic.contains(sentinel));
3262 }
3263 }
3264
3265 let reasoning = ProviderTranscriptItem::try_from_payload(
3266 ProviderFamily::OpenAi,
3267 ProviderProtocol::OpenAiResponsesV1,
3268 ProviderTranscriptOrigin::Provider,
3269 ProviderTranscriptAuthor::Model,
3270 json!({
3271 "id":"rs_1","type":"reasoning","status":"completed",
3272 "summary":[{"type":"summary_text","text":"bounded summary"}],
3273 "encrypted_content":"opaque"
3274 }),
3275 )
3276 .unwrap();
3277 assert_eq!(
3278 reasoning.kind(),
3279 ProviderTranscriptItemKind::OpenAiReasoning
3280 );
3281
3282 let thinking = ProviderTranscriptItem::try_from_payload(
3283 ProviderFamily::Anthropic,
3284 ProviderProtocol::AnthropicMessages2023_06_01,
3285 ProviderTranscriptOrigin::Provider,
3286 ProviderTranscriptAuthor::Model,
3287 json!({"type":"thinking","thinking":"private","signature":"signed"}),
3288 )
3289 .unwrap();
3290 assert_eq!(
3291 thinking.kind(),
3292 ProviderTranscriptItemKind::AnthropicThinking
3293 );
3294 }
3295
3296 #[test]
3297 fn every_supported_payload_layer_rejects_unknown_fields() {
3298 let mut baselines = openai_output_items();
3299 baselines.extend(anthropic_items());
3300 baselines.extend([
3301 ProviderTranscriptItem::try_from_payload(
3302 ProviderFamily::OpenAi,
3303 ProviderProtocol::OpenAiResponsesV1,
3304 ProviderTranscriptOrigin::Provider,
3305 ProviderTranscriptAuthor::Model,
3306 json!({
3307 "type":"message","id":"msg_1","role":"assistant","status":"completed",
3308 "content":[{"type":"output_text","text":"done","annotations":[]}]
3309 }),
3310 )
3311 .unwrap(),
3312 ProviderTranscriptItem::try_from_payload(
3313 ProviderFamily::OpenAi,
3314 ProviderProtocol::OpenAiResponsesV1,
3315 ProviderTranscriptOrigin::Provider,
3316 ProviderTranscriptAuthor::Model,
3317 json!({
3318 "type":"reasoning","id":"rs_1","status":"completed",
3319 "summary":[{"type":"summary_text","text":"safe"}]
3320 }),
3321 )
3322 .unwrap(),
3323 ProviderTranscriptItem::try_from_payload(
3324 ProviderFamily::OpenAi,
3325 ProviderProtocol::OpenAiResponsesV1,
3326 ProviderTranscriptOrigin::HostToolSearch,
3327 ProviderTranscriptAuthor::ToolResult,
3328 json!({
3329 "type":"tool_search_output","execution":"client","call_id":"search_client",
3330 "status":"completed","tools":[]
3331 }),
3332 )
3333 .unwrap(),
3334 ProviderTranscriptItem::try_from_payload(
3335 ProviderFamily::OpenAi,
3336 ProviderProtocol::OpenAiResponsesV1,
3337 ProviderTranscriptOrigin::DeveloperContext,
3338 ProviderTranscriptAuthor::Host,
3339 json!({"type":"additional_tools","role":"developer","tools":[]}),
3340 )
3341 .unwrap(),
3342 ProviderTranscriptItem::try_from_payload(
3343 ProviderFamily::Anthropic,
3344 ProviderProtocol::AnthropicMessages2023_06_01,
3345 ProviderTranscriptOrigin::Provider,
3346 ProviderTranscriptAuthor::Model,
3347 json!({"type":"thinking","thinking":"private","signature":"opaque"}),
3348 )
3349 .unwrap(),
3350 ProviderTranscriptItem::try_from_payload(
3351 ProviderFamily::Anthropic,
3352 ProviderProtocol::AnthropicMessages2023_06_01,
3353 ProviderTranscriptOrigin::Provider,
3354 ProviderTranscriptAuthor::Model,
3355 json!({"type":"redacted_thinking","data":"opaque"}),
3356 )
3357 .unwrap(),
3358 ProviderTranscriptItem::try_from_payload(
3359 ProviderFamily::Anthropic,
3360 ProviderProtocol::AnthropicMessages2023_06_01,
3361 ProviderTranscriptOrigin::HostToolSearch,
3362 ProviderTranscriptAuthor::ToolResult,
3363 json!({
3364 "type":"tool_result","tool_use_id":"toolu_search","is_error":false,
3365 "content":[{"type":"tool_reference","tool_name":"get_weather"}]
3366 }),
3367 )
3368 .unwrap(),
3369 ]);
3370 for baseline in &baselines {
3371 assert_unknown_top_level_rejected(baseline);
3372 }
3373
3374 assert_payload_rejected_without_leak(
3375 ProviderFamily::OpenAi,
3376 ProviderProtocol::OpenAiResponsesV1,
3377 ProviderTranscriptOrigin::Provider,
3378 ProviderTranscriptAuthor::Model,
3379 json!({
3380 "type":"message","id":"msg_nested","role":"assistant","status":"completed",
3381 "content":[{
3382 "type":"output_text","text":"safe","annotations":[],
3383 "unknown":"UNKNOWN_FIELD_SENTINEL"
3384 }]
3385 }),
3386 );
3387 assert_payload_rejected_without_leak(
3388 ProviderFamily::OpenAi,
3389 ProviderProtocol::OpenAiResponsesV1,
3390 ProviderTranscriptOrigin::Provider,
3391 ProviderTranscriptAuthor::Model,
3392 json!({
3393 "type":"message","id":"msg_annotation","role":"assistant","status":"completed",
3394 "content":[{
3395 "type":"output_text","text":"safe","annotations":[{
3396 "type":"file_citation","file_id":"file_1","filename":"safe.txt","index":0,
3397 "unknown":"UNKNOWN_FIELD_SENTINEL"
3398 }]
3399 }]
3400 }),
3401 );
3402 assert_payload_rejected_without_leak(
3403 ProviderFamily::OpenAi,
3404 ProviderProtocol::OpenAiResponsesV1,
3405 ProviderTranscriptOrigin::Provider,
3406 ProviderTranscriptAuthor::Model,
3407 json!({
3408 "type":"message","id":"msg_logprob","role":"assistant","status":"completed",
3409 "content":[{
3410 "type":"output_text","text":"safe","annotations":[],"logprobs":[{
3411 "token":"safe","bytes":[115],"logprob":-0.1,"top_logprobs":[],
3412 "unknown":"UNKNOWN_FIELD_SENTINEL"
3413 }]
3414 }]
3415 }),
3416 );
3417 assert_payload_rejected_without_leak(
3418 ProviderFamily::OpenAi,
3419 ProviderProtocol::OpenAiResponsesV1,
3420 ProviderTranscriptOrigin::Provider,
3421 ProviderTranscriptAuthor::Model,
3422 json!({
3423 "type":"reasoning","id":"rs_nested","summary":[{
3424 "type":"summary_text","text":"safe","unknown":"UNKNOWN_FIELD_SENTINEL"
3425 }]
3426 }),
3427 );
3428 assert_payload_rejected_without_leak(
3429 ProviderFamily::OpenAi,
3430 ProviderProtocol::OpenAiResponsesV1,
3431 ProviderTranscriptOrigin::Provider,
3432 ProviderTranscriptAuthor::ToolResult,
3433 json!({
3434 "type":"tool_search_output","id":"tso_nested","execution":"server",
3435 "call_id":"search_nested","status":"completed","tools":[{
3436 "type":"function","name":"safe_tool","unknown":"UNKNOWN_FIELD_SENTINEL"
3437 }]
3438 }),
3439 );
3440 assert_payload_rejected_without_leak(
3441 ProviderFamily::OpenAi,
3442 ProviderProtocol::OpenAiResponsesV1,
3443 ProviderTranscriptOrigin::Provider,
3444 ProviderTranscriptAuthor::Model,
3445 json!({
3446 "type":"tool_search_call","id":"tsc_agent","execution":"client",
3447 "call_id":"search_agent","status":"completed","arguments":{},
3448 "agent":{"agent_name":"safe","unknown":"UNKNOWN_FIELD_SENTINEL"}
3449 }),
3450 );
3451 assert_payload_rejected_without_leak(
3452 ProviderFamily::Anthropic,
3453 ProviderProtocol::AnthropicMessages2023_06_01,
3454 ProviderTranscriptOrigin::Provider,
3455 ProviderTranscriptAuthor::Model,
3456 json!({
3457 "type":"text","text":"safe","citations":[{
3458 "type":"web_search_result_location","cited_text":"safe",
3459 "encrypted_index":"opaque","title":"safe","url":"https://example.invalid",
3460 "unknown":"UNKNOWN_FIELD_SENTINEL"
3461 }]
3462 }),
3463 );
3464 assert_payload_rejected_without_leak(
3465 ProviderFamily::Anthropic,
3466 ProviderProtocol::AnthropicMessages2023_06_01,
3467 ProviderTranscriptOrigin::Provider,
3468 ProviderTranscriptAuthor::Model,
3469 json!({
3470 "type":"tool_use","id":"toolu_caller","name":"safe_tool","input":{},
3471 "caller":{"type":"direct","unknown":"UNKNOWN_FIELD_SENTINEL"}
3472 }),
3473 );
3474 assert_payload_rejected_without_leak(
3475 ProviderFamily::Anthropic,
3476 ProviderProtocol::AnthropicMessages2023_06_01,
3477 ProviderTranscriptOrigin::Provider,
3478 ProviderTranscriptAuthor::ToolResult,
3479 json!({
3480 "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3481 "content":{
3482 "type":"tool_search_tool_search_result","tool_references":[
3483 {"type":"tool_reference","tool_name":"safe_tool"}
3484 ],
3485 "unknown":"UNKNOWN_FIELD_SENTINEL"
3486 }
3487 }),
3488 );
3489 assert_payload_rejected_without_leak(
3490 ProviderFamily::Anthropic,
3491 ProviderProtocol::AnthropicMessages2023_06_01,
3492 ProviderTranscriptOrigin::Provider,
3493 ProviderTranscriptAuthor::ToolResult,
3494 json!({
3495 "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3496 "content":{
3497 "type":"tool_search_tool_search_result","tool_references":[{
3498 "type":"tool_reference","tool_name":"safe_tool",
3499 "unknown":"UNKNOWN_FIELD_SENTINEL"
3500 }]
3501 }
3502 }),
3503 );
3504 assert_payload_rejected_without_leak(
3505 ProviderFamily::Anthropic,
3506 ProviderProtocol::AnthropicMessages2023_06_01,
3507 ProviderTranscriptOrigin::Provider,
3508 ProviderTranscriptAuthor::ToolResult,
3509 json!({
3510 "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3511 "content":{
3512 "type":"tool_search_tool_result_error","error_code":"unavailable",
3513 "error_message":null,"unknown":"UNKNOWN_FIELD_SENTINEL"
3514 }
3515 }),
3516 );
3517 }
3518
3519 #[test]
3520 fn hosted_discovery_chains_reject_dangling_reordered_and_mismatched_uses() {
3521 let openai = openai_output_items();
3522 let mut reordered = openai.clone();
3523 reordered.swap(1, 2);
3524 assert_eq!(
3525 test_group("openai", reordered).unwrap_err(),
3526 ProviderTranscriptError::InvalidGroupOrder
3527 );
3528 assert_eq!(
3529 test_group("openai", vec![openai[0].clone()]).unwrap_err(),
3530 ProviderTranscriptError::InvalidGroupOrder
3531 );
3532 let mismatched_call = ProviderTranscriptItem::try_from_payload(
3533 ProviderFamily::OpenAi,
3534 ProviderProtocol::OpenAiResponsesV1,
3535 ProviderTranscriptOrigin::Provider,
3536 ProviderTranscriptAuthor::Model,
3537 json!({
3538 "type":"function_call","call_id":"call_other",
3539 "name":"not_loaded","arguments":"{}"
3540 }),
3541 )
3542 .unwrap();
3543 assert_eq!(
3544 test_group(
3545 "openai",
3546 vec![openai[0].clone(), openai[1].clone(), mismatched_call],
3547 )
3548 .unwrap_err(),
3549 ProviderTranscriptError::InvalidGroupOrder
3550 );
3551
3552 let hosted_item = |author, payload| {
3553 ProviderTranscriptItem::try_from_payload(
3554 ProviderFamily::OpenAi,
3555 ProviderProtocol::OpenAiResponsesV1,
3556 ProviderTranscriptOrigin::Provider,
3557 author,
3558 payload,
3559 )
3560 .unwrap()
3561 };
3562 let call_two = hosted_item(
3563 ProviderTranscriptAuthor::Model,
3564 json!({
3565 "type":"tool_search_call","id":"tsc_2","execution":"server",
3566 "call_id":"search_2","status":"completed","arguments":{"query":"support"}
3567 }),
3568 );
3569 let output_two = hosted_item(
3570 ProviderTranscriptAuthor::ToolResult,
3571 json!({
3572 "type":"tool_search_output","id":"tso_2","execution":"server",
3573 "call_id":"search_2","status":"completed",
3574 "tools":[{"type":"function","name":"list_support_cases"}]
3575 }),
3576 );
3577 let function_two = hosted_item(
3578 ProviderTranscriptAuthor::Model,
3579 json!({
3580 "type":"function_call","id":"fc_2","call_id":"function_2",
3581 "name":"list_support_cases","arguments":"{}","status":"completed"
3582 }),
3583 );
3584 let parallel = vec![
3585 openai[0].clone(),
3586 call_two.clone(),
3587 openai[1].clone(),
3588 output_two.clone(),
3589 openai[2].clone(),
3590 function_two,
3591 ];
3592 assert!(test_group("openai-parallel", parallel).is_ok());
3593
3594 let unkeyed_parallel = vec![
3595 hosted_item(
3596 ProviderTranscriptAuthor::Model,
3597 json!({
3598 "type":"tool_search_call","id":"tsc_unkeyed_1","execution":"server",
3599 "call_id":null,"status":"completed","arguments":{"query":"first"}
3600 }),
3601 ),
3602 hosted_item(
3603 ProviderTranscriptAuthor::Model,
3604 json!({
3605 "type":"tool_search_call","id":"tsc_unkeyed_2","execution":"server",
3606 "status":"completed","arguments":{"query":"second"}
3607 }),
3608 ),
3609 hosted_item(
3610 ProviderTranscriptAuthor::ToolResult,
3611 json!({
3612 "type":"tool_search_output","id":"tso_unkeyed_1","execution":"server",
3613 "call_id":null,"status":"completed","tools":[]
3614 }),
3615 ),
3616 hosted_item(
3617 ProviderTranscriptAuthor::ToolResult,
3618 json!({
3619 "type":"tool_search_output","id":"tso_unkeyed_2","execution":"server",
3620 "status":"completed","tools":[]
3621 }),
3622 ),
3623 ];
3624 assert!(test_group("openai-unkeyed-parallel", unkeyed_parallel).is_ok());
3625
3626 let mismatched_output = hosted_item(
3627 ProviderTranscriptAuthor::ToolResult,
3628 json!({
3629 "type":"tool_search_output","id":"tso_mismatch","execution":"server",
3630 "call_id":"search_missing","status":"completed","tools":[]
3631 }),
3632 );
3633 assert_eq!(
3634 test_group(
3635 "openai-mismatch",
3636 vec![openai[0].clone(), mismatched_output],
3637 )
3638 .unwrap_err(),
3639 ProviderTranscriptError::InvalidGroupOrder
3640 );
3641
3642 let duplicate_id_call = hosted_item(
3643 ProviderTranscriptAuthor::Model,
3644 json!({
3645 "type":"tool_search_call","id":"tsc_1","execution":"server",
3646 "call_id":"search_duplicate_id","status":"completed","arguments":{}
3647 }),
3648 );
3649 assert_eq!(
3650 test_group(
3651 "openai-duplicate-id",
3652 vec![openai[0].clone(), duplicate_id_call],
3653 )
3654 .unwrap_err(),
3655 ProviderTranscriptError::InvalidGroupOrder
3656 );
3657
3658 let duplicate_call_id = hosted_item(
3659 ProviderTranscriptAuthor::Model,
3660 json!({
3661 "type":"tool_search_call","id":"tsc_duplicate_call","execution":"server",
3662 "call_id":"search_1","status":"completed","arguments":{}
3663 }),
3664 );
3665 assert_eq!(
3666 test_group(
3667 "openai-duplicate-call",
3668 vec![openai[0].clone(), duplicate_call_id],
3669 )
3670 .unwrap_err(),
3671 ProviderTranscriptError::InvalidGroupOrder
3672 );
3673 assert!(ProviderTranscriptItem::try_from_payload(
3674 ProviderFamily::OpenAi,
3675 ProviderProtocol::OpenAiResponsesV1,
3676 ProviderTranscriptOrigin::Provider,
3677 ProviderTranscriptAuthor::Model,
3678 json!({
3679 "type":"tool_search_call","execution":"server","call_id":"search_missing_id",
3680 "status":"completed","arguments":{}
3681 }),
3682 )
3683 .is_err());
3684 for call_id in [json!(""), json!(7)] {
3685 assert!(ProviderTranscriptItem::try_from_payload(
3686 ProviderFamily::OpenAi,
3687 ProviderProtocol::OpenAiResponsesV1,
3688 ProviderTranscriptOrigin::Provider,
3689 ProviderTranscriptAuthor::Model,
3690 json!({
3691 "type":"tool_search_call","id":"tsc_invalid_call","execution":"server",
3692 "call_id":call_id,"status":"completed","arguments":{}
3693 }),
3694 )
3695 .is_err());
3696 }
3697 ProviderTranscriptItem::try_from_payload(
3698 ProviderFamily::OpenAi,
3699 ProviderProtocol::OpenAiResponsesV1,
3700 ProviderTranscriptOrigin::Provider,
3701 ProviderTranscriptAuthor::Model,
3702 json!({
3703 "type":"tool_search_call","id":"tsc_missing_call","execution":"server",
3704 "status":"completed","arguments":{}
3705 }),
3706 )
3707 .expect("the official hosted output model permits an omitted call_id");
3708
3709 for malformed_client in [
3710 json!({
3711 "type":"tool_search_call","id":"tsc_client_missing","execution":"client",
3712 "status":"completed","arguments":{}
3713 }),
3714 json!({
3715 "type":"tool_search_call","id":"tsc_client_null","execution":"client",
3716 "call_id":null,"status":"completed","arguments":{}
3717 }),
3718 json!({
3719 "type":"tool_search_call","id":"tsc_client_empty","execution":"client",
3720 "call_id":"","status":"completed","arguments":{}
3721 }),
3722 json!({
3723 "type":"tool_search_call","id":"tsc_client_typed","execution":"client",
3724 "call_id":7,"status":"completed","arguments":{}
3725 }),
3726 ] {
3727 assert!(ProviderTranscriptItem::try_from_payload(
3728 ProviderFamily::OpenAi,
3729 ProviderProtocol::OpenAiResponsesV1,
3730 ProviderTranscriptOrigin::Provider,
3731 ProviderTranscriptAuthor::Model,
3732 malformed_client,
3733 )
3734 .is_err());
3735 }
3736
3737 let client_call = ProviderTranscriptItem::try_from_payload(
3740 ProviderFamily::OpenAi,
3741 ProviderProtocol::OpenAiResponsesV1,
3742 ProviderTranscriptOrigin::Provider,
3743 ProviderTranscriptAuthor::Model,
3744 json!({
3745 "type":"tool_search_call","id":"tsc_client_1","execution":"client","call_id":"search_1",
3746 "status":"completed","arguments":{"query":"weather"}
3747 }),
3748 )
3749 .unwrap();
3750 assert!(test_group("client-call", vec![client_call.clone()]).is_ok());
3751 let premature_function_call = hosted_item(
3752 ProviderTranscriptAuthor::Model,
3753 json!({
3754 "type":"function_call","id":"fc_client_premature","call_id":"function_1",
3755 "name":"get_weather","arguments":"{}","status":"completed"
3756 }),
3757 );
3758 assert_eq!(
3759 test_group(
3760 "client-call-must-stop",
3761 vec![client_call.clone(), premature_function_call.clone()],
3762 )
3763 .unwrap_err(),
3764 ProviderTranscriptError::InvalidGroupOrder
3765 );
3766 assert_eq!(
3767 test_group(
3768 "function-cannot-precede-client-call",
3769 vec![premature_function_call, client_call.clone()],
3770 )
3771 .unwrap_err(),
3772 ProviderTranscriptError::InvalidGroupOrder
3773 );
3774
3775 let host_client_output = ProviderTranscriptItem::try_from_payload(
3776 ProviderFamily::OpenAi,
3777 ProviderProtocol::OpenAiResponsesV1,
3778 ProviderTranscriptOrigin::HostToolSearch,
3779 ProviderTranscriptAuthor::ToolResult,
3780 json!({
3781 "type":"tool_search_output","execution":"client",
3782 "call_id":"search_1","status":"completed","tools":[]
3783 }),
3784 )
3785 .unwrap();
3786 assert_eq!(
3787 test_group(
3788 "client-call-cannot-contain-host-output",
3789 vec![client_call.clone(), host_client_output.clone()],
3790 )
3791 .unwrap_err(),
3792 ProviderTranscriptError::InvalidGroupOrder
3793 );
3794 assert_eq!(
3795 test_group(
3796 "host-output-cannot-precede-client-call",
3797 vec![host_client_output.clone(), client_call],
3798 )
3799 .unwrap_err(),
3800 ProviderTranscriptError::InvalidGroupOrder
3801 );
3802 assert!(test_group("standalone-host-output", vec![host_client_output]).is_ok());
3803
3804 let anthropic = anthropic_items();
3805 let mut reordered = anthropic.clone();
3806 reordered.swap(2, 3);
3807 assert_eq!(
3808 test_group("anthropic", reordered).unwrap_err(),
3809 ProviderTranscriptError::InvalidGroupOrder
3810 );
3811 assert_eq!(
3812 test_group("anthropic", vec![anthropic[1].clone()]).unwrap_err(),
3813 ProviderTranscriptError::InvalidGroupOrder
3814 );
3815 let mismatched_use = ProviderTranscriptItem::try_from_payload(
3816 ProviderFamily::Anthropic,
3817 ProviderProtocol::AnthropicMessages2023_06_01,
3818 ProviderTranscriptOrigin::Provider,
3819 ProviderTranscriptAuthor::Model,
3820 json!({"type":"tool_use","id":"tool_other","name":"not_loaded","input":{}}),
3821 )
3822 .unwrap();
3823 assert_eq!(
3824 test_group(
3825 "anthropic",
3826 vec![anthropic[1].clone(), anthropic[2].clone(), mismatched_use],
3827 )
3828 .unwrap_err(),
3829 ProviderTranscriptError::InvalidGroupOrder
3830 );
3831 }
3832
3833 #[test]
3834 fn anthropic_groups_require_one_leading_thinking_block_and_unique_tool_use_ids() {
3835 let item = |author, payload| {
3836 ProviderTranscriptItem::try_from_payload(
3837 ProviderFamily::Anthropic,
3838 ProviderProtocol::AnthropicMessages2023_06_01,
3839 ProviderTranscriptOrigin::Provider,
3840 author,
3841 payload,
3842 )
3843 .unwrap()
3844 };
3845 let thinking = item(
3846 ProviderTranscriptAuthor::Model,
3847 json!({"type":"thinking","thinking":"private","signature":"signed"}),
3848 );
3849 let redacted = item(
3850 ProviderTranscriptAuthor::Model,
3851 json!({"type":"redacted_thinking","data":"opaque"}),
3852 );
3853 let base = anthropic_items();
3854
3855 let mut leading = vec![thinking.clone()];
3856 leading.extend(base.clone());
3857 assert!(test_group("anthropic-leading-thinking", leading).is_ok());
3858
3859 let mut interior = base.clone();
3860 interior.insert(1, thinking.clone());
3861 assert_eq!(
3862 test_group("anthropic-interior-thinking", interior).unwrap_err(),
3863 ProviderTranscriptError::InvalidGroupOrder
3864 );
3865
3866 let mut multiple = vec![thinking, redacted];
3867 multiple.extend(base.clone());
3868 assert_eq!(
3869 test_group("anthropic-multiple-thinking", multiple).unwrap_err(),
3870 ProviderTranscriptError::InvalidGroupOrder
3871 );
3872
3873 let duplicate_id = item(
3874 ProviderTranscriptAuthor::Model,
3875 json!({
3876 "type":"tool_use","id":"toolu_01XYZ789",
3877 "name":"get_weather","input":{}
3878 }),
3879 );
3880 let mut duplicate = base.clone();
3881 duplicate.push(duplicate_id);
3882 assert_eq!(
3883 test_group("anthropic-duplicate-tool-id", duplicate).unwrap_err(),
3884 ProviderTranscriptError::InvalidGroupOrder
3885 );
3886
3887 let server_id_collision = item(
3888 ProviderTranscriptAuthor::Model,
3889 json!({
3890 "type":"tool_use","id":"srvtoolu_01ABC123",
3891 "name":"get_weather","input":{}
3892 }),
3893 );
3894 let mut collision = base;
3895 collision.push(server_id_collision);
3896 assert_eq!(
3897 test_group("anthropic-cross-type-tool-id", collision).unwrap_err(),
3898 ProviderTranscriptError::InvalidGroupOrder
3899 );
3900 }
3901
3902 #[test]
3903 fn anthropic_parallel_search_results_pair_by_server_id() {
3904 let item = |author, payload| {
3905 ProviderTranscriptItem::try_from_payload(
3906 ProviderFamily::Anthropic,
3907 ProviderProtocol::AnthropicMessages2023_06_01,
3908 ProviderTranscriptOrigin::Provider,
3909 author,
3910 payload,
3911 )
3912 .unwrap()
3913 };
3914 let parallel = vec![
3915 item(
3916 ProviderTranscriptAuthor::Model,
3917 json!({
3918 "type":"server_tool_use","id":"srv_1",
3919 "name":"tool_search_tool_regex","input":{"pattern":"weather"}
3920 }),
3921 ),
3922 item(
3923 ProviderTranscriptAuthor::Model,
3924 json!({
3925 "type":"server_tool_use","id":"srv_2",
3926 "name":"tool_search_tool_bm25","input":{"query":"calendar"}
3927 }),
3928 ),
3929 item(
3930 ProviderTranscriptAuthor::ToolResult,
3931 json!({
3932 "type":"tool_search_tool_result","tool_use_id":"srv_2",
3933 "content":{"type":"tool_search_tool_search_result","tool_references":[
3934 {"type":"tool_reference","tool_name":"get_calendar"}
3935 ]}
3936 }),
3937 ),
3938 item(
3939 ProviderTranscriptAuthor::ToolResult,
3940 json!({
3941 "type":"tool_search_tool_result","tool_use_id":"srv_1",
3942 "content":{"type":"tool_search_tool_search_result","tool_references":[
3943 {"type":"tool_reference","tool_name":"get_weather"}
3944 ]}
3945 }),
3946 ),
3947 item(
3948 ProviderTranscriptAuthor::Model,
3949 json!({"type":"tool_use","id":"tool_1","name":"get_weather","input":{}}),
3950 ),
3951 item(
3952 ProviderTranscriptAuthor::Model,
3953 json!({"type":"tool_use","id":"tool_2","name":"get_calendar","input":{}}),
3954 ),
3955 ];
3956 assert!(test_group("anthropic-parallel", parallel.clone()).is_ok());
3957
3958 let mut result_before_call = parallel;
3959 result_before_call.swap(1, 2);
3960 assert_eq!(
3961 test_group("anthropic-result-before-call", result_before_call).unwrap_err(),
3962 ProviderTranscriptError::InvalidGroupOrder
3963 );
3964 }
3965
3966 #[test]
3967 fn stable_ids_are_canonical_and_isolated_by_provider_identity() {
3968 let payload_a: Value = serde_json::from_str(
3969 r#"{"type":"tool_search_call","id":"tsc_stable","execution":"client","call_id":"search_1","status":"completed","arguments":{"b":2,"a":1}}"#,
3970 )
3971 .unwrap();
3972 let payload_b: Value = serde_json::from_str(
3973 r#"{"arguments":{"a":1,"b":2},"status":"completed","call_id":"search_1","execution":"client","id":"tsc_stable","type":"tool_search_call"}"#,
3974 )
3975 .unwrap();
3976 let item = |family, payload| {
3977 ProviderTranscriptItem::try_from_payload(
3978 family,
3979 ProviderProtocol::OpenAiResponsesV1,
3980 ProviderTranscriptOrigin::Provider,
3981 ProviderTranscriptAuthor::Model,
3982 payload,
3983 )
3984 .unwrap()
3985 };
3986 let openai_a = item(ProviderFamily::OpenAi, payload_a);
3987 let openai_b = item(ProviderFamily::OpenAi, payload_b);
3988 let copilot = item(ProviderFamily::Copilot, openai_a.payload().clone());
3989 assert_eq!(openai_a.id(), openai_b.id());
3990 assert_ne!(openai_a.id(), copilot.id());
3991
3992 let route_a = test_boundary("openai-route-a", "openai");
3993 let route_b = test_boundary("openai-route-b", "openai");
3994 let openai_group = ProviderTranscriptGroup::new(
3995 0,
3996 0,
3997 "anchor".to_string(),
3998 None,
3999 route_a.clone(),
4000 vec![openai_a.clone()],
4001 )
4002 .unwrap();
4003 let other_route_group =
4004 ProviderTranscriptGroup::new(0, 0, "anchor".to_string(), None, route_b, vec![openai_a])
4005 .unwrap();
4006 let copilot_group =
4007 ProviderTranscriptGroup::new(0, 0, "anchor".to_string(), None, route_a, vec![copilot])
4008 .unwrap();
4009 assert_ne!(openai_group.id(), other_route_group.id());
4010 assert_ne!(openai_group.id(), copilot_group.id());
4011
4012 let mut tampered = serde_json::to_value(openai_b).unwrap();
4013 tampered["id"] = json!("pti_tampered");
4014 assert!(serde_json::from_value::<ProviderTranscriptItem>(tampered).is_err());
4015 }
4016
4017 #[test]
4018 fn session_serialization_preserves_groups_and_old_sessions_default_empty() {
4019 let mut session = Session::new("session-native", "gpt-5.6");
4020 let assistant = Message::assistant("", None);
4021 let anchor = assistant.id.clone();
4022 session.add_message(assistant);
4023 activate_openai(&mut session);
4024 session
4025 .append_provider_transcript_group(&anchor, Some("resp_123"), openai_output_items())
4026 .unwrap();
4027
4028 let encoded = serde_json::to_string(&session).unwrap();
4029 let decoded: Session = serde_json::from_str(&encoded).unwrap();
4030 assert_eq!(decoded.provider_transcript, session.provider_transcript);
4031 assert_eq!(replayable_openai(&decoded).len(), 1);
4032 let mut compressed = decoded;
4033 compressed.reset_model_context_epoch(crate::session::ModelContextResetReason::Compression);
4034 assert!(replayable_openai(&compressed).is_empty());
4035 assert_eq!(compressed.provider_transcript.groups().len(), 1);
4036
4037 let mut old = serde_json::to_value(Session::new("old", "model")).unwrap();
4038 old.as_object_mut().unwrap().remove("provider_transcript");
4039 let old: Session = serde_json::from_value(old).unwrap();
4040 assert!(old.provider_transcript.is_empty());
4041 }
4042
4043 #[test]
4044 fn persisted_state_rejects_future_epochs_and_reused_current_sequences() {
4045 let mut session = Session::new("session-native", "gpt-5.6");
4046 let assistant = Message::assistant("", None);
4047 let anchor = assistant.id.clone();
4048 session.add_message(assistant);
4049 session
4050 .append_provider_transcript_group(&anchor, None, openai_output_items())
4051 .unwrap();
4052
4053 let mut future = serde_json::to_value(&session).unwrap();
4054 future["provider_transcript"]["groups"][0]["epoch"] = json!(1);
4055 assert!(serde_json::from_value::<Session>(future).is_err());
4056
4057 let mut reused = serde_json::to_value(&session).unwrap();
4058 reused["provider_transcript"]["next_sequence"] = json!(0);
4059 assert!(serde_json::from_value::<Session>(reused).is_err());
4060
4061 let mut missing_version = serde_json::to_value(&session).unwrap();
4062 missing_version["provider_transcript"]
4063 .as_object_mut()
4064 .unwrap()
4065 .remove("schema_version");
4066 assert!(serde_json::from_value::<Session>(missing_version).is_err());
4067
4068 let mut future_version = serde_json::to_value(&session).unwrap();
4069 future_version["provider_transcript"]["schema_version"] =
4070 json!(PROVIDER_TRANSCRIPT_SCHEMA_VERSION + 1);
4071 assert!(serde_json::from_value::<Session>(future_version).is_err());
4072
4073 let explicitly_empty: ProviderTranscriptState = serde_json::from_value(json!({})).unwrap();
4074 assert!(explicitly_empty.is_empty());
4075 }
4076
4077 #[test]
4078 fn rollback_prunes_the_whole_atomic_group() {
4079 let mut session = Session::new("session-native", "claude");
4080 let assistant = Message::assistant("", None);
4081 let anchor = assistant.id.clone();
4082 session.add_message(assistant);
4083 activate_anthropic(&mut session);
4084 session
4085 .append_provider_transcript_group(&anchor, None, anthropic_items())
4086 .unwrap();
4087 session.messages.clear();
4088
4089 assert_eq!(session.prune_provider_transcript(), 1);
4090 assert!(session.provider_transcript.groups().is_empty());
4091 assert_eq!(
4092 session.provider_transcript.last_reset_reason(),
4093 Some(ProviderTranscriptResetReason::Rollback)
4094 );
4095 }
4096
4097 #[test]
4098 fn explicit_history_rewrite_invalidates_groups_even_when_anchors_survive() {
4099 let mut session = Session::new("session-native", "gpt-5.6");
4100 let assistant = Message::assistant("before edit", None);
4101 let anchor = assistant.id.clone();
4102 session.add_message(assistant);
4103 session
4104 .append_provider_transcript_group(&anchor, None, openai_output_items())
4105 .unwrap();
4106 let previous_epoch = session.provider_transcript.epoch();
4107
4108 session.messages[0].content = "after edit".to_string();
4109 session.reset_model_context_epoch(
4110 crate::session::ModelContextResetReason::ExplicitHistoryRewrite,
4111 );
4112
4113 assert_eq!(session.provider_transcript.groups().len(), 1);
4114 assert_eq!(session.provider_transcript.epoch(), previous_epoch + 1);
4115 assert!(replayable_openai(&session).is_empty());
4116 assert_eq!(
4117 session.provider_transcript.last_reset_reason(),
4118 Some(ProviderTranscriptResetReason::ExplicitHistoryRewrite)
4119 );
4120 }
4121
4122 #[test]
4123 fn durable_and_runner_native_groups_merge_append_safely() {
4124 let mut base = Session::new("session-native", "gpt-5.6");
4125 let first = Message::assistant("first", None);
4126 let first_id = first.id.clone();
4127 let second = Message::assistant("second", None);
4128 let second_id = second.id.clone();
4129 base.add_message(first);
4130 base.add_message(second);
4131 let mut durable = base.clone();
4132 durable
4133 .append_provider_transcript_group(&first_id, None, openai_output_items())
4134 .unwrap();
4135 let mut runner = base;
4136 runner
4137 .append_provider_transcript_group(&second_id, None, openai_output_items())
4138 .unwrap();
4139
4140 crate::session::append_missing_runtime_messages(&mut runner, &durable);
4141 assert_eq!(runner.provider_transcript.groups().len(), 2);
4142 assert_eq!(
4143 replayable_openai(&runner)
4144 .into_iter()
4145 .map(ProviderTranscriptGroup::anchor_message_id)
4146 .collect::<Vec<_>>(),
4147 vec![first_id.as_str(), second_id.as_str()]
4148 );
4149 }
4150
4151 #[test]
4152 fn equal_revision_merge_is_deterministic_for_same_anchor_branches() {
4153 let assistant = Message::assistant("anchor", None);
4154 let anchor = assistant.id.clone();
4155 let mut base = Session::new("session-native", "gpt-5.6");
4156 base.add_message(assistant);
4157
4158 let mut left = base.clone();
4159 left.append_provider_transcript_group(&anchor, None, openai_output_items())
4160 .unwrap();
4161 let mut right_items = openai_output_items();
4162 right_items[0] = ProviderTranscriptItem::try_from_payload(
4163 ProviderFamily::OpenAi,
4164 ProviderProtocol::OpenAiResponsesV1,
4165 ProviderTranscriptOrigin::Provider,
4166 ProviderTranscriptAuthor::Model,
4167 json!({
4168 "type":"tool_search_call","id":"tsc_support","execution":"server","call_id":"search_1",
4169 "status":"completed","arguments":{"paths":["support"]}
4170 }),
4171 )
4172 .unwrap();
4173 let mut right = base;
4174 right
4175 .append_provider_transcript_group(&anchor, None, right_items)
4176 .unwrap();
4177
4178 let left_original = left.provider_transcript.clone();
4179 let right_original = right.provider_transcript.clone();
4180 let ordered = vec![anchor];
4181 left.provider_transcript
4182 .merge_durable_prefix(&right_original, &ordered);
4183 right
4184 .provider_transcript
4185 .merge_durable_prefix(&left_original, &ordered);
4186 assert_eq!(left.provider_transcript, right.provider_transcript);
4187 assert_eq!(replayable_openai(&left).len(), 2);
4188 }
4189
4190 #[test]
4191 fn rejected_append_is_atomic_and_reset_reason_survives_new_epoch_append() {
4192 let mut state = ProviderTranscriptState::default();
4193 state
4194 .activate_route(
4195 ProviderFamily::Anthropic,
4196 ProviderProtocol::AnthropicMessages2023_06_01,
4197 &anthropic_boundary(),
4198 )
4199 .unwrap();
4200 let before = state.clone();
4201 assert_eq!(
4202 state
4203 .append_group("anchor", None, openai_output_items())
4204 .unwrap_err(),
4205 ProviderTranscriptError::InactiveProviderRoute
4206 );
4207 assert_eq!(state, before, "rejected append must not mutate state");
4208
4209 let first = Message::assistant("openai", None);
4210 let first_anchor = first.id.clone();
4211 let second = Message::assistant("anthropic", None);
4212 let second_anchor = second.id.clone();
4213 let mut session = Session::new("switch-audit", "model");
4214 session.add_message(first);
4215 session.add_message(second);
4216 session
4217 .append_provider_transcript_group(&first_anchor, None, openai_output_items())
4218 .unwrap();
4219 activate_anthropic(&mut session);
4220 session
4221 .append_provider_transcript_group(&second_anchor, None, anthropic_items())
4222 .unwrap();
4223 let round_trip: Session =
4224 serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap();
4225 assert_eq!(
4226 round_trip.provider_transcript.last_reset_reason(),
4227 Some(ProviderTranscriptResetReason::ProviderSwitch)
4228 );
4229 assert_eq!(
4230 round_trip
4231 .provider_transcript
4232 .replayable_groups(
4233 ProviderFamily::Anthropic,
4234 ProviderProtocol::AnthropicMessages2023_06_01,
4235 &anthropic_boundary(),
4236 )
4237 .len(),
4238 1
4239 );
4240 }
4241
4242 #[test]
4243 fn append_merge_never_overwrites_a_newer_provider_epoch_with_more_old_groups() {
4244 let mut base = Session::new("session-native", "model");
4245 let anchors = (0..3)
4246 .map(|index| {
4247 let assistant = Message::assistant(format!("assistant {index}"), None);
4248 let anchor = assistant.id.clone();
4249 base.add_message(assistant);
4250 anchor
4251 })
4252 .collect::<Vec<_>>();
4253 let mut durable = base.clone();
4254 durable
4255 .append_provider_transcript_group(&anchors[0], None, openai_output_items())
4256 .unwrap();
4257 durable
4258 .append_provider_transcript_group(&anchors[1], None, openai_output_items())
4259 .unwrap();
4260
4261 let mut runner = base;
4262 runner
4263 .append_provider_transcript_group(&anchors[2], None, openai_output_items())
4264 .unwrap();
4265 activate_anthropic(&mut runner);
4266 let switched_epoch = runner.provider_transcript.epoch();
4267
4268 crate::session::append_missing_runtime_messages(&mut runner, &durable);
4269 assert_eq!(runner.provider_transcript.epoch(), switched_epoch);
4270 assert_eq!(
4271 runner.provider_transcript.active_family(),
4272 Some(ProviderFamily::Anthropic)
4273 );
4274 assert_eq!(
4275 runner.provider_transcript.last_reset_reason(),
4276 Some(ProviderTranscriptResetReason::ProviderSwitch)
4277 );
4278 assert!(replayable_openai(&runner).is_empty());
4279 }
4280
4281 #[test]
4282 fn provider_switch_starts_a_new_epoch_and_filters_foreign_json() {
4283 let mut session = Session::new("session-native", "model");
4284 let assistant = Message::assistant("", None);
4285 let anchor = assistant.id.clone();
4286 session.add_message(assistant);
4287 activate_openai(&mut session);
4288 session
4289 .append_provider_transcript_group(&anchor, None, openai_output_items())
4290 .unwrap();
4291 let old_epoch = session.provider_transcript.epoch();
4292
4293 assert!(activate_anthropic(&mut session));
4294 assert_eq!(session.provider_transcript.epoch(), old_epoch + 1);
4295 assert_eq!(
4296 session.provider_transcript.last_reset_reason(),
4297 Some(ProviderTranscriptResetReason::ProviderSwitch)
4298 );
4299 assert!(replayable_openai(&session).is_empty());
4300 }
4301
4302 #[test]
4303 fn same_family_provider_instance_switch_advances_and_persists_route_boundary() {
4304 let route_a_name = "openai-instance-a-sensitive-route";
4305 let route_b_name = "openai-instance-b-sensitive-route";
4306 let route_a = test_boundary(route_a_name, "openai");
4307 let route_b = test_boundary(route_b_name, "openai");
4308 assert_ne!(route_a, route_b);
4309 assert!(!route_a.contains(route_a_name));
4310
4311 let mut session = Session::new("same-family-switch", "model");
4312 let assistant = Message::assistant("native", None);
4313 let anchor = assistant.id.clone();
4314 session.add_message(assistant);
4315 assert!(session
4316 .activate_provider_transcript_route(
4317 ProviderFamily::OpenAi,
4318 ProviderProtocol::OpenAiResponsesV1,
4319 &route_a,
4320 )
4321 .unwrap());
4322 session
4323 .append_provider_transcript_group(&anchor, None, openai_output_items())
4324 .unwrap();
4325 let first_epoch = session.provider_transcript.epoch();
4326 assert_eq!(
4327 session
4328 .provider_transcript
4329 .active_provider_boundary_sha256(),
4330 Some(route_a.as_str())
4331 );
4332 assert!(!session
4333 .activate_provider_transcript_route(
4334 ProviderFamily::OpenAi,
4335 ProviderProtocol::OpenAiResponsesV1,
4336 &route_a,
4337 )
4338 .unwrap());
4339 assert_eq!(session.provider_transcript.epoch(), first_epoch);
4340
4341 let encoded = serde_json::to_string(&session.provider_transcript).unwrap();
4342 assert!(!encoded.contains(route_a_name));
4343 assert!(!encoded.contains(route_b_name));
4344
4345 let mut active_boundary_tamper: Value = serde_json::from_str(&encoded).unwrap();
4346 active_boundary_tamper["active_provider_boundary_sha256"] = json!(route_b);
4347 assert!(
4348 serde_json::from_value::<ProviderTranscriptState>(active_boundary_tamper).is_err(),
4349 "a valid but foreign active boundary must not reclassify current groups"
4350 );
4351
4352 let mut group_boundary_tamper: Value = serde_json::from_str(&encoded).unwrap();
4353 group_boundary_tamper["groups"][0]["provider_boundary_sha256"] = json!(route_b);
4354 assert!(
4355 serde_json::from_value::<ProviderTranscriptState>(group_boundary_tamper).is_err(),
4356 "a valid but foreign group boundary must invalidate its stable id"
4357 );
4358
4359 let mut resumed: ProviderTranscriptState = serde_json::from_str(&encoded).unwrap();
4360 assert_eq!(
4361 resumed.active_provider_boundary_sha256(),
4362 Some(route_a.as_str())
4363 );
4364 assert!(resumed
4365 .activate_route(
4366 ProviderFamily::OpenAi,
4367 ProviderProtocol::OpenAiResponsesV1,
4368 &route_b,
4369 )
4370 .unwrap());
4371 assert_eq!(resumed.epoch(), first_epoch + 1);
4372 assert_eq!(
4373 resumed.last_reset_reason(),
4374 Some(ProviderTranscriptResetReason::ProviderSwitch)
4375 );
4376 assert!(resumed
4377 .replayable_groups(
4378 ProviderFamily::OpenAi,
4379 ProviderProtocol::OpenAiResponsesV1,
4380 &route_a,
4381 )
4382 .is_empty());
4383 assert!(resumed
4384 .replayable_groups(
4385 ProviderFamily::OpenAi,
4386 ProviderProtocol::OpenAiResponsesV1,
4387 &route_b,
4388 )
4389 .is_empty());
4390
4391 let mut invalid = serde_json::to_value(&resumed).unwrap();
4392 invalid["active_provider_boundary_sha256"] = json!("not-a-sha256");
4393 assert!(serde_json::from_value::<ProviderTranscriptState>(invalid).is_err());
4394 }
4395
4396 #[test]
4397 fn stale_same_family_route_cannot_resurrect_groups_during_merge() {
4398 let route_a = test_boundary("openai-instance-a", "openai");
4399 let route_b = test_boundary("openai-instance-b", "openai");
4400 let mut base = Session::new("same-family-merge", "model");
4401 let first = Message::assistant("first", None);
4402 let first_anchor = first.id.clone();
4403 let second = Message::assistant("second", None);
4404 let second_anchor = second.id.clone();
4405 base.add_message(first);
4406 base.add_message(second);
4407 base.activate_provider_transcript_route(
4408 ProviderFamily::OpenAi,
4409 ProviderProtocol::OpenAiResponsesV1,
4410 &route_a,
4411 )
4412 .unwrap();
4413 base.append_provider_transcript_group(&first_anchor, None, openai_output_items())
4414 .unwrap();
4415
4416 let mut durable = base.clone();
4417 durable
4418 .activate_provider_transcript_route(
4419 ProviderFamily::OpenAi,
4420 ProviderProtocol::OpenAiResponsesV1,
4421 &route_b,
4422 )
4423 .unwrap();
4424 let switched_epoch = durable.provider_transcript.epoch();
4425
4426 let mut stale_runner = base;
4427 stale_runner
4428 .append_provider_transcript_group(&second_anchor, None, openai_output_items())
4429 .unwrap();
4430 stale_runner.merge_provider_transcript_from_durable(&durable);
4431 assert_eq!(stale_runner.provider_transcript.epoch(), switched_epoch);
4432 assert_eq!(
4433 stale_runner
4434 .provider_transcript
4435 .active_provider_boundary_sha256(),
4436 Some(route_b.as_str())
4437 );
4438 assert!(stale_runner
4439 .provider_transcript
4440 .replayable_groups(
4441 ProviderFamily::OpenAi,
4442 ProviderProtocol::OpenAiResponsesV1,
4443 &route_b,
4444 )
4445 .is_empty());
4446 assert_eq!(
4447 stale_runner.provider_transcript.last_reset_reason(),
4448 Some(ProviderTranscriptResetReason::ProviderSwitch)
4449 );
4450 }
4451
4452 #[test]
4453 fn debug_and_diagnostics_never_emit_raw_payload() {
4454 let secret = "credential-do-not-log";
4455 let item = ProviderTranscriptItem::try_from_payload(
4456 ProviderFamily::OpenAi,
4457 ProviderProtocol::OpenAiResponsesV1,
4458 ProviderTranscriptOrigin::DeveloperContext,
4459 ProviderTranscriptAuthor::Host,
4460 json!({
4461 "type":"additional_tools",
4462 "role":"developer",
4463 "tools":[{
4464 "type":"function","name":"x","description":secret,
4465 "parameters":{"type":"object"},"strict":false
4466 }]
4467 }),
4468 )
4469 .unwrap();
4470 assert!(!format!("{item:?}").contains(secret));
4471 let group = test_group(secret, vec![item]).unwrap();
4472 assert!(!format!("{group:?}").contains(secret));
4473
4474 let error = ProviderTranscriptItem::try_from_payload(
4475 ProviderFamily::OpenAi,
4476 ProviderProtocol::OpenAiResponsesV1,
4477 ProviderTranscriptOrigin::Provider,
4478 ProviderTranscriptAuthor::Model,
4479 json!({"type":secret}),
4480 )
4481 .unwrap_err();
4482 assert!(!format!("{error:?} {error}").contains(secret));
4483 }
4484}