Skip to main content

clark_agent/
reasoning.rs

1//! Provider-agnostic reasoning replay.
2//!
3//! Captures provider-native reasoning content from one assistant turn and
4//! replays it on the next, in a shape each provider accepts. Mirrors
5//! OpenRouter's `reasoning_details[]` schema (the broadest typed surface
6//! across providers): every item is one of three variants — plain
7//! reasoning text (with optional opaque signature), provider-emitted
8//! summary, or fully-encrypted blob — tagged with the originating
9//! provider's `format` discriminator.
10//!
11//! Why this exists: providers split on two axes. **Where the handle
12//! attaches** (Gemini binds `thoughtSignature` to a specific `Part`;
13//! Anthropic binds `signature` to a `thinking` content block;
14//! OpenAI/xAI carry `encrypted_content` on a separate reasoning input
15//! item) and **whether replay is required** (Gemini 3 always-with-tools;
16//! Anthropic only when next turn carries `tool_result`; OpenAI when a
17//! function call is in the turn; Grok stateless). A flat opaque-bytes
18//! abstraction collapses the attachment point and breaks Gemini.
19//!
20//! The right shape: typed enum with provider-shaped variants, and the
21//! per-provider `ReasoningCodec` knows how to read each native shape on
22//! response and emit each native shape on the next request. The bridge
23//! stores the typed enum verbatim; the codec is a stateless translator
24//! between typed item and provider wire shape.
25
26use serde::{Deserialize, Serialize};
27use serde_json::{Map, Value};
28
29/// Tagged provider source for a reasoning item.
30///
31/// Mirrors the `format` field on OpenRouter's `reasoning_details`
32/// items, plus an `Unknown` fallback so we never silently drop a
33/// payload from an unrecognized future provider.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum ReasoningFormat {
37    /// `format: "anthropic-claude-v1"` — Claude `thinking` /
38    /// `redacted_thinking` blocks; `signature` field on the
39    /// `Text` variant is mandatory round-trip when next turn has
40    /// `tool_result`.
41    AnthropicClaudeV1,
42    /// `format: "google-gemini-v1"` — Vertex AI / AI Studio
43    /// Gemini 2.5+. `Encrypted` variant carries the
44    /// `thoughtSignature` blob OpenRouter explodes out of the
45    /// per-`Part` attachment. Gemini 3 with tools requires exact
46    /// round-trip or returns 400 INVALID_ARGUMENT "Thought
47    /// signature is not valid".
48    GoogleGeminiV1,
49    /// `format: "openai-responses-v1"` — OpenAI o-series via the
50    /// Responses API. `Encrypted.data` holds `encrypted_content`;
51    /// callers must opt in via `include: ["reasoning.encrypted_content"]`.
52    OpenaiResponsesV1,
53    /// `format: "azure-openai-responses-v1"` — Azure variant of
54    /// the same shape.
55    AzureOpenaiResponsesV1,
56    /// `format: "xai-responses-v1"` — xAI Grok via the Responses
57    /// API.
58    XaiResponsesV1,
59    /// Anything else. Round-tripped opaquely; the codec preserves
60    /// the original `Value` as `raw` so a future provider never
61    /// silently loses fidelity.
62    #[serde(other)]
63    Unknown,
64}
65
66impl ReasoningFormat {
67    pub fn as_wire(&self) -> &'static str {
68        match self {
69            ReasoningFormat::AnthropicClaudeV1 => "anthropic-claude-v1",
70            ReasoningFormat::GoogleGeminiV1 => "google-gemini-v1",
71            ReasoningFormat::OpenaiResponsesV1 => "openai-responses-v1",
72            ReasoningFormat::AzureOpenaiResponsesV1 => "azure-openai-responses-v1",
73            ReasoningFormat::XaiResponsesV1 => "xai-responses-v1",
74            ReasoningFormat::Unknown => "unknown",
75        }
76    }
77
78    pub fn from_wire(s: &str) -> Self {
79        match s {
80            "anthropic-claude-v1" => ReasoningFormat::AnthropicClaudeV1,
81            "google-gemini-v1" => ReasoningFormat::GoogleGeminiV1,
82            "openai-responses-v1" => ReasoningFormat::OpenaiResponsesV1,
83            "azure-openai-responses-v1" => ReasoningFormat::AzureOpenaiResponsesV1,
84            "xai-responses-v1" => ReasoningFormat::XaiResponsesV1,
85            _ => ReasoningFormat::Unknown,
86        }
87    }
88
89    /// Replay contract this provider format imposes on the next turn.
90    ///
91    /// Each variant's enforcement story lives in the variant's own
92    /// docs; this method is the typed projection the audit pipeline
93    /// reads. Centralizing here keeps the audit free of per-provider
94    /// switches and gives new providers exactly one place to declare
95    /// their contract.
96    pub fn replay_contract(&self) -> ReplayContract {
97        match self {
98            ReasoningFormat::GoogleGeminiV1 => ReplayContract::RequiredWithTools,
99            ReasoningFormat::AnthropicClaudeV1 => ReplayContract::RequiredWithTools,
100            ReasoningFormat::OpenaiResponsesV1 | ReasoningFormat::AzureOpenaiResponsesV1 => {
101                ReplayContract::RequiredWithTools
102            }
103            ReasoningFormat::XaiResponsesV1 => ReplayContract::Stateless,
104            ReasoningFormat::Unknown => ReplayContract::Stateless,
105        }
106    }
107}
108
109/// One provider-emitted reasoning element.
110///
111/// Three variants cover every shape known across the major providers:
112/// readable reasoning text (with optional Anthropic-style signature),
113/// summary blurbs (OpenAI/Anthropic summaries), and fully-encrypted
114/// blobs (Gemini `thoughtSignature`, OpenAI `encrypted_content`,
115/// Anthropic `redacted_thinking`). Round-trip identity is the contract:
116/// every byte the provider emitted must come back unchanged on the
117/// next assistant message.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(tag = "type")]
120pub enum ReasoningItem {
121    /// `reasoning.text` — visible reasoning content. The
122    /// `signature` field is opaque and mandatory round-trip on
123    /// providers that emit it (Anthropic).
124    #[serde(rename = "reasoning.text")]
125    Text {
126        #[serde(skip_serializing_if = "Option::is_none")]
127        id: Option<String>,
128        format: ReasoningFormat,
129        #[serde(skip_serializing_if = "Option::is_none")]
130        index: Option<u32>,
131        text: String,
132        #[serde(skip_serializing_if = "Option::is_none")]
133        signature: Option<String>,
134    },
135    /// `reasoning.summary` — provider-emitted summary of internal
136    /// reasoning; carries no signature, but still must replay
137    /// verbatim to preserve token-cache continuity on some
138    /// providers.
139    #[serde(rename = "reasoning.summary")]
140    Summary {
141        #[serde(skip_serializing_if = "Option::is_none")]
142        id: Option<String>,
143        format: ReasoningFormat,
144        #[serde(skip_serializing_if = "Option::is_none")]
145        index: Option<u32>,
146        summary: String,
147    },
148    /// `reasoning.encrypted` — opaque base64 blob. Gemini's
149    /// `thoughtSignature` is delivered in this shape via
150    /// OpenRouter; OpenAI's `encrypted_content` and Anthropic's
151    /// `redacted_thinking.data` likewise. The bridge stores
152    /// `data` byte-for-byte and the codec attaches it back to the
153    /// right wire location for the originating provider.
154    #[serde(rename = "reasoning.encrypted")]
155    Encrypted {
156        #[serde(skip_serializing_if = "Option::is_none")]
157        id: Option<String>,
158        format: ReasoningFormat,
159        #[serde(skip_serializing_if = "Option::is_none")]
160        index: Option<u32>,
161        data: String,
162    },
163}
164
165impl ReasoningItem {
166    /// Originating-provider tag.
167    pub fn format(&self) -> ReasoningFormat {
168        match self {
169            ReasoningItem::Text { format, .. } => *format,
170            ReasoningItem::Summary { format, .. } => *format,
171            ReasoningItem::Encrypted { format, .. } => *format,
172        }
173    }
174
175    /// `index` if the provider supplied one. OpenRouter uses this
176    /// to preserve order across heterogeneous variants when the
177    /// provider emits text and encrypted blobs interleaved.
178    pub fn index(&self) -> Option<u32> {
179        match self {
180            ReasoningItem::Text { index, .. } => *index,
181            ReasoningItem::Summary { index, .. } => *index,
182            ReasoningItem::Encrypted { index, .. } => *index,
183        }
184    }
185
186    /// True iff this item carries a signature/encrypted payload
187    /// the provider will reject if missing on replay. Used by
188    /// `ReasoningCodec` to validate "did we receive what we need
189    /// for the next turn?" without having to inspect the inner
190    /// fields.
191    pub fn carries_signed_payload(&self) -> bool {
192        matches!(
193            self,
194            ReasoningItem::Text {
195                signature: Some(_),
196                ..
197            } | ReasoningItem::Encrypted { .. }
198        )
199    }
200
201    /// Round-trip from a `Value` shaped exactly as OpenRouter's
202    /// `reasoning_details[]` item. Returns `None` if the input is
203    /// not an object with a recognized `type` discriminator.
204    pub fn from_openrouter_value(value: &Value) -> Option<Self> {
205        let obj = value.as_object()?;
206        let kind = obj.get("type").and_then(Value::as_str)?;
207        let format = obj
208            .get("format")
209            .and_then(Value::as_str)
210            .map(ReasoningFormat::from_wire)
211            .unwrap_or(ReasoningFormat::Unknown);
212        let id = obj
213            .get("id")
214            .and_then(Value::as_str)
215            .map(str::to_string)
216            .or_else(|| obj.get("id").and_then(|v| v.as_null()).and(None));
217        let index = obj.get("index").and_then(Value::as_u64).map(|n| n as u32);
218
219        match kind {
220            "reasoning.text" => Some(ReasoningItem::Text {
221                id,
222                format,
223                index,
224                text: obj
225                    .get("text")
226                    .and_then(Value::as_str)
227                    .unwrap_or("")
228                    .to_string(),
229                signature: obj
230                    .get("signature")
231                    .and_then(Value::as_str)
232                    .map(str::to_string),
233            }),
234            "reasoning.summary" => Some(ReasoningItem::Summary {
235                id,
236                format,
237                index,
238                summary: obj
239                    .get("summary")
240                    .and_then(Value::as_str)
241                    .unwrap_or("")
242                    .to_string(),
243            }),
244            "reasoning.encrypted" => Some(ReasoningItem::Encrypted {
245                id,
246                format,
247                index,
248                data: obj
249                    .get("data")
250                    .and_then(Value::as_str)
251                    .unwrap_or("")
252                    .to_string(),
253            }),
254            _ => None,
255        }
256    }
257
258    /// Serialize back to the OpenRouter wire shape. Round-trip
259    /// fidelity: `from_openrouter_value(v).unwrap().to_openrouter_value() == v`
260    /// for any well-formed input.
261    pub fn to_openrouter_value(&self) -> Value {
262        let mut map = Map::new();
263        match self {
264            ReasoningItem::Text {
265                id,
266                format,
267                index,
268                text,
269                signature,
270            } => {
271                map.insert("type".into(), Value::String("reasoning.text".into()));
272                if let Some(id) = id {
273                    map.insert("id".into(), Value::String(id.clone()));
274                }
275                map.insert("format".into(), Value::String(format.as_wire().into()));
276                if let Some(index) = index {
277                    map.insert("index".into(), Value::Number((*index).into()));
278                }
279                map.insert("text".into(), Value::String(text.clone()));
280                if let Some(sig) = signature {
281                    map.insert("signature".into(), Value::String(sig.clone()));
282                }
283            }
284            ReasoningItem::Summary {
285                id,
286                format,
287                index,
288                summary,
289            } => {
290                map.insert("type".into(), Value::String("reasoning.summary".into()));
291                if let Some(id) = id {
292                    map.insert("id".into(), Value::String(id.clone()));
293                }
294                map.insert("format".into(), Value::String(format.as_wire().into()));
295                if let Some(index) = index {
296                    map.insert("index".into(), Value::Number((*index).into()));
297                }
298                map.insert("summary".into(), Value::String(summary.clone()));
299            }
300            ReasoningItem::Encrypted {
301                id,
302                format,
303                index,
304                data,
305            } => {
306                map.insert("type".into(), Value::String("reasoning.encrypted".into()));
307                if let Some(id) = id {
308                    map.insert("id".into(), Value::String(id.clone()));
309                }
310                map.insert("format".into(), Value::String(format.as_wire().into()));
311                if let Some(index) = index {
312                    map.insert("index".into(), Value::Number((*index).into()));
313                }
314                map.insert("data".into(), Value::String(data.clone()));
315            }
316        }
317        Value::Object(map)
318    }
319}
320
321/// Whether this turn's reasoning items satisfy the next-turn replay
322/// contract for the originating provider.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum ReplayContract {
325    /// Provider has no replay requirement (e.g. xAI Grok via chat
326    /// completions, Qwen / Kimi / Llama, or any model not yet
327    /// enrolled in the audit). Stateless.
328    Stateless,
329    /// Provider requires every signed/encrypted item to round-trip
330    /// exactly when the upcoming turn carries tool calls / tool
331    /// results. Missing → 400.
332    RequiredWithTools,
333    /// Provider requires every signed/encrypted item to round-trip
334    /// every turn, irrespective of tools (Gemini 3 Pro).
335    AlwaysRequired,
336}
337
338/// Plug-and-play translator between provider-native reasoning shapes
339/// and Clark's typed `ReasoningItem` representation.
340///
341/// Per-provider implementations live in their respective `StreamFn`
342/// modules. The agent loop never knows about provider quirks; it
343/// holds typed items and asks the codec to read/write the wire shape.
344/// Replay contracts are a property of the originating
345/// [`ReasoningFormat`], not the codec — see
346/// [`ReasoningFormat::replay_contract`].
347///
348/// The default `OpenRouterReasoningCodec` covers every provider that
349/// flows through OpenRouter, since OpenRouter normalizes all upstream
350/// reasoning into the same `reasoning_details[]` schema.
351pub trait ReasoningCodec: Send + Sync {
352    /// Lift OpenRouter-shaped `reasoning_details[]` (or the native
353    /// equivalent) into typed items. Values whose `type` discriminator
354    /// matches no known variant are dropped from the typed view; the
355    /// raw `Value` array is still kept on the assistant message so
356    /// round-trip fidelity is preserved on replay.
357    fn parse_response(&self, raw: &[Value]) -> Vec<ReasoningItem>;
358
359    /// Project typed items back into the assistant message body that
360    /// will be sent on the next request. The default emits an
361    /// `reasoning_details` array on the assistant message. Providers
362    /// with positional contracts (Vertex direct: signature must be
363    /// re-attached to the `functionCall` Part) override.
364    fn write_assistant(&self, msg: &mut Value, items: &[ReasoningItem]);
365}
366
367/// Diagnostic about a single assistant turn's reasoning state.
368///
369/// Produced by [`audit_replay`]. The bridge uses this to surface
370/// "the next request will 400" failures eagerly — observable now, in
371/// the right log line, instead of as a confusing upstream HTTP error
372/// after the request goes out.
373#[derive(Debug, Clone, PartialEq)]
374pub struct ReplayAudit {
375    /// Total reasoning items captured this turn.
376    pub item_count: usize,
377    /// Items that carry a signature or encrypted blob the
378    /// originating provider will check on replay.
379    pub signed_count: usize,
380    /// Per-format breakdown so the bridge can route warnings to
381    /// the right provider-specific log channel.
382    pub formats: Vec<ReasoningFormat>,
383    /// Severity of any contract break the audit detected. `None`
384    /// means the turn satisfies its codec's replay contract.
385    pub violation: Option<ReplayViolation>,
386}
387
388/// A specific way the audited turn would fail upstream on replay.
389///
390/// The bridge maps each variant to a typed log/event so operators can
391/// distinguish "this provider produced no signatures and will reject
392/// the next request" from "this provider produced malformed items".
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum ReplayViolation {
395    /// Provider's contract is `RequiredWithTools` (or stricter), the
396    /// upcoming turn carries tool calls / tool results, and zero
397    /// signed/encrypted items were captured. Next request will be
398    /// rejected with a contract error (Gemini 3 returns
399    /// `INVALID_ARGUMENT "Thought signature is not valid"`).
400    MissingSignaturesForStrictProvider {
401        contract: ReplayContract,
402        formats: Vec<ReasoningFormat>,
403    },
404}
405
406/// Audit a turn's reasoning items against the codec's replay contract.
407///
408/// `next_turn_carries_tools` should be `true` when the upcoming
409/// request will include tool calls or tool results — that's the
410/// trigger for `RequiredWithTools` providers. Stateless and
411/// `Optional` codecs always return a clean audit.
412pub fn audit_replay(
413    items: &[ReasoningItem],
414    contract: ReplayContract,
415    next_turn_carries_tools: bool,
416) -> ReplayAudit {
417    let signed_count = items.iter().filter(|i| i.carries_signed_payload()).count();
418    let mut formats: Vec<ReasoningFormat> = items.iter().map(ReasoningItem::format).collect();
419    formats.sort_by_key(|f| f.as_wire());
420    formats.dedup();
421
422    let violation = match contract {
423        ReplayContract::Stateless => None,
424        ReplayContract::RequiredWithTools if !next_turn_carries_tools => None,
425        ReplayContract::RequiredWithTools | ReplayContract::AlwaysRequired => {
426            if signed_count == 0 {
427                Some(ReplayViolation::MissingSignaturesForStrictProvider {
428                    contract,
429                    formats: formats.clone(),
430                })
431            } else {
432                None
433            }
434        }
435    };
436
437    ReplayAudit {
438        item_count: items.len(),
439        signed_count,
440        formats,
441        violation,
442    }
443}
444
445/// Default codec: reads OpenRouter's `reasoning_details[]` schema on
446/// response, writes the same on the assistant message for replay.
447/// Covers every provider OpenRouter routes to (Anthropic, Google,
448/// OpenAI, xAI, Azure, etc.) because OpenRouter normalizes them all
449/// to this shape.
450#[derive(Debug, Clone, Default)]
451pub struct OpenRouterReasoningCodec;
452
453impl OpenRouterReasoningCodec {
454    pub fn new() -> Self {
455        Self
456    }
457}
458
459impl ReasoningCodec for OpenRouterReasoningCodec {
460    fn parse_response(&self, raw: &[Value]) -> Vec<ReasoningItem> {
461        raw.iter()
462            .filter_map(ReasoningItem::from_openrouter_value)
463            .collect()
464    }
465
466    fn write_assistant(&self, msg: &mut Value, items: &[ReasoningItem]) {
467        if items.is_empty() {
468            return;
469        }
470        let arr = items
471            .iter()
472            .map(ReasoningItem::to_openrouter_value)
473            .collect::<Vec<_>>();
474        if let Some(obj) = msg.as_object_mut() {
475            obj.insert("reasoning_details".into(), Value::Array(arr));
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use serde_json::json;
484
485    #[test]
486    fn format_wire_roundtrip_covers_every_variant() {
487        for fmt in [
488            ReasoningFormat::AnthropicClaudeV1,
489            ReasoningFormat::GoogleGeminiV1,
490            ReasoningFormat::OpenaiResponsesV1,
491            ReasoningFormat::AzureOpenaiResponsesV1,
492            ReasoningFormat::XaiResponsesV1,
493            ReasoningFormat::Unknown,
494        ] {
495            assert_eq!(ReasoningFormat::from_wire(fmt.as_wire()), fmt);
496        }
497    }
498
499    #[test]
500    fn unknown_format_falls_back() {
501        assert_eq!(
502            ReasoningFormat::from_wire("future-provider-v9"),
503            ReasoningFormat::Unknown
504        );
505    }
506
507    #[test]
508    fn item_text_roundtrip() {
509        let v = json!({
510            "type": "reasoning.text",
511            "id": "rs-1",
512            "format": "anthropic-claude-v1",
513            "index": 0,
514            "text": "First, compare the decimals.",
515            "signature": "sig-abc"
516        });
517        let item = ReasoningItem::from_openrouter_value(&v).unwrap();
518        match &item {
519            ReasoningItem::Text {
520                format, signature, ..
521            } => {
522                assert_eq!(*format, ReasoningFormat::AnthropicClaudeV1);
523                assert_eq!(signature.as_deref(), Some("sig-abc"));
524            }
525            _ => panic!("expected Text variant"),
526        }
527        assert_eq!(item.to_openrouter_value(), v);
528    }
529
530    #[test]
531    fn item_encrypted_roundtrip_for_gemini() {
532        let v = json!({
533            "type": "reasoning.encrypted",
534            "id": "rs-2",
535            "format": "google-gemini-v1",
536            "index": 3,
537            "data": "BASE64BLOB"
538        });
539        let item = ReasoningItem::from_openrouter_value(&v).unwrap();
540        match &item {
541            ReasoningItem::Encrypted { format, data, .. } => {
542                assert_eq!(*format, ReasoningFormat::GoogleGeminiV1);
543                assert_eq!(data, "BASE64BLOB");
544            }
545            _ => panic!("expected Encrypted variant"),
546        }
547        assert!(item.carries_signed_payload());
548        assert_eq!(item.to_openrouter_value(), v);
549    }
550
551    #[test]
552    fn item_summary_roundtrip() {
553        let v = json!({
554            "type": "reasoning.summary",
555            "id": "rs-3",
556            "format": "openai-responses-v1",
557            "index": 1,
558            "summary": "Compared 9.9 and 9.11 numerically."
559        });
560        let item = ReasoningItem::from_openrouter_value(&v).unwrap();
561        assert!(matches!(item, ReasoningItem::Summary { .. }));
562        assert!(!item.carries_signed_payload());
563        assert_eq!(item.to_openrouter_value(), v);
564    }
565
566    #[test]
567    fn unknown_type_returns_none() {
568        let v = json!({"type": "reasoning.future_kind", "data": "..." });
569        assert!(ReasoningItem::from_openrouter_value(&v).is_none());
570    }
571
572    #[test]
573    fn carries_signed_payload_distinguishes_signed_from_unsigned_text() {
574        let signed = ReasoningItem::Text {
575            id: None,
576            format: ReasoningFormat::AnthropicClaudeV1,
577            index: Some(0),
578            text: "thought".into(),
579            signature: Some("sig".into()),
580        };
581        let unsigned = ReasoningItem::Text {
582            id: None,
583            format: ReasoningFormat::Unknown,
584            index: None,
585            text: "thought".into(),
586            signature: None,
587        };
588        assert!(signed.carries_signed_payload());
589        assert!(!unsigned.carries_signed_payload());
590    }
591
592    #[test]
593    fn openrouter_codec_parses_and_writes_assistant() {
594        let codec = OpenRouterReasoningCodec::new();
595        let raw = vec![
596            json!({
597                "type": "reasoning.encrypted",
598                "format": "google-gemini-v1",
599                "index": 0,
600                "data": "GBLOB"
601            }),
602            json!({"type": "noise"}),
603        ];
604        let items = codec.parse_response(&raw);
605        assert_eq!(items.len(), 1);
606        assert_eq!(items[0].format(), ReasoningFormat::GoogleGeminiV1);
607
608        let mut msg = json!({"role": "assistant", "content": null});
609        codec.write_assistant(&mut msg, &items);
610        let arr = msg
611            .as_object()
612            .unwrap()
613            .get("reasoning_details")
614            .unwrap()
615            .as_array()
616            .unwrap();
617        assert_eq!(arr.len(), 1);
618        assert_eq!(arr[0]["data"], "GBLOB");
619    }
620
621    #[test]
622    fn openrouter_codec_skips_empty_replay() {
623        let codec = OpenRouterReasoningCodec::new();
624        let mut msg = json!({"role": "assistant", "content": "hi"});
625        codec.write_assistant(&mut msg, &[]);
626        assert!(msg.as_object().unwrap().get("reasoning_details").is_none());
627    }
628
629    #[test]
630    fn format_replay_contract_matches_observed_provider_enforcement() {
631        // RequiredWithTools — providers that enforce signed reasoning
632        // round-trip on tool-bearing turns.
633        for fmt in [
634            ReasoningFormat::GoogleGeminiV1,
635            ReasoningFormat::AnthropicClaudeV1,
636            ReasoningFormat::OpenaiResponsesV1,
637            ReasoningFormat::AzureOpenaiResponsesV1,
638        ] {
639            assert_eq!(
640                fmt.replay_contract(),
641                ReplayContract::RequiredWithTools,
642                "{fmt:?} should be RequiredWithTools"
643            );
644        }
645        // Stateless — Grok and unrecognized future providers, where a
646        // missing-signature warning would be a false positive.
647        for fmt in [ReasoningFormat::XaiResponsesV1, ReasoningFormat::Unknown] {
648            assert_eq!(
649                fmt.replay_contract(),
650                ReplayContract::Stateless,
651                "{fmt:?} should be Stateless"
652            );
653        }
654    }
655
656    #[test]
657    fn audit_clean_when_provider_is_stateless() {
658        let audit = audit_replay(&[], ReplayContract::Stateless, true);
659        assert!(audit.violation.is_none());
660        assert_eq!(audit.signed_count, 0);
661    }
662
663    #[test]
664    fn audit_clean_when_required_with_tools_but_next_turn_has_no_tools() {
665        let audit = audit_replay(&[], ReplayContract::RequiredWithTools, false);
666        assert!(audit.violation.is_none());
667    }
668
669    #[test]
670    fn audit_flags_missing_signatures_for_required_with_tools() {
671        let items = vec![ReasoningItem::Text {
672            id: None,
673            format: ReasoningFormat::GoogleGeminiV1,
674            index: Some(0),
675            text: "thinking out loud".into(),
676            signature: None,
677        }];
678        let audit = audit_replay(&items, ReplayContract::RequiredWithTools, true);
679        match audit.violation {
680            Some(ReplayViolation::MissingSignaturesForStrictProvider {
681                contract, formats, ..
682            }) => {
683                assert_eq!(contract, ReplayContract::RequiredWithTools);
684                assert_eq!(formats, vec![ReasoningFormat::GoogleGeminiV1]);
685            }
686            _ => panic!("expected MissingSignaturesForStrictProvider violation"),
687        }
688    }
689
690    #[test]
691    fn audit_passes_when_signed_payload_present() {
692        let items = vec![
693            ReasoningItem::Text {
694                id: None,
695                format: ReasoningFormat::GoogleGeminiV1,
696                index: Some(0),
697                text: "summary".into(),
698                signature: None,
699            },
700            ReasoningItem::Encrypted {
701                id: None,
702                format: ReasoningFormat::GoogleGeminiV1,
703                index: Some(1),
704                data: "BASE64".into(),
705            },
706        ];
707        let audit = audit_replay(&items, ReplayContract::RequiredWithTools, true);
708        assert!(audit.violation.is_none());
709        assert_eq!(audit.signed_count, 1);
710        assert_eq!(audit.item_count, 2);
711    }
712
713    #[test]
714    fn audit_always_required_fires_even_without_tools() {
715        let audit = audit_replay(&[], ReplayContract::AlwaysRequired, false);
716        assert!(matches!(
717            audit.violation,
718            Some(ReplayViolation::MissingSignaturesForStrictProvider { .. })
719        ));
720    }
721}