Skip to main content

rig_core/test_utils/
streaming_conformance.rs

1//! Wire-sequence conformance scenarios for provider streaming pipelines.
2//!
3//! The streaming sibling of `rig-agent`'s `model_conformance`: each scenario
4//! drives raw wire bytes (SSE or NDJSON) through a provider's *complete*
5//! streaming path — bytes → decode → the writer's parts →
6//! [`CompletionStream`](crate::streaming::CompletionStream) → its response
7//! — and asserts the streaming contract: an error is the stream's last item,
8//! a reply the provider did not end has no response, and every part the
9//! stream emits reaches the response exactly once. Scenarios state the contract; a per-provider
10//! [`ProviderWireFixture`] supplies the frames, since each wire format spells
11//! the same event differently.
12//!
13//! Every sequence family here pins a shipped bug from the #2257 review rounds
14//! (`rig-2257-code-review-findings-*.md`); the per-scenario comments cite the
15//! specific finding.
16//!
17//! Suites are expanded per wire family by
18//! [`streaming_conformance_suite!`](crate::streaming_conformance_suite).
19//! Scenarios a wire cannot spell return an explicit
20//! [`ScenarioOutcome::Skipped`] that the macro cross-checks against the
21//! suite's declared [`SuiteCapabilities`] — a skip is always visible and can
22//! never masquerade as a pass, so the executed count is exactly the declared
23//! grid minus the named skips (#2258 review, F8 corpus honesty).
24
25use bytes::Bytes;
26use futures::StreamExt;
27use futures::future::BoxFuture;
28
29use crate::completion::{CompletionRequest, CompletionResponse};
30use crate::error::ProviderError;
31use crate::{
32    completion::FinishReason,
33    error::ErrorReport,
34    http_client,
35    message::AssistantContent,
36    streaming::{Item, StreamEvent},
37};
38
39/// Typed failure from a wire-conformance scenario.
40#[derive(Debug, thiserror::Error)]
41pub enum ConformanceError {
42    /// Opening the stream failed before any wire frame was consumed.
43    #[error(transparent)]
44    Completion(#[from] ProviderError),
45    /// The pipeline violated the streaming contract table.
46    #[error("{scenario} conformance failed for {provider}: {details}")]
47    Contract {
48        /// Stable scenario name.
49        scenario: &'static str,
50        /// Provider driver under test.
51        provider: &'static str,
52        /// Actionable observation explaining the failure.
53        details: String,
54    },
55}
56
57impl ConformanceError {
58    fn contract(
59        scenario: &'static str,
60        provider: &'static str,
61        details: impl Into<String>,
62    ) -> Self {
63        Self::Contract {
64            scenario,
65            provider,
66            details: details.into(),
67        }
68    }
69}
70
71/// Outcome of a passing wire-conformance scenario.
72#[derive(Debug)]
73pub struct ScenarioReport {
74    /// Stable scenario name.
75    pub name: &'static str,
76    /// Provider driver the scenario ran against.
77    pub provider: &'static str,
78    /// Human-readable observations, one per verified sub-case.
79    pub observations: Vec<String>,
80}
81
82/// What a capability-gated scenario did: ran its assertions, or skipped
83/// because the wire family cannot spell the sequence shape.
84///
85/// A skip is an explicit, named outcome — never a silent pass. The
86/// [`streaming_conformance_suite!`](crate::streaming_conformance_suite)
87/// macro cross-checks it against the suite's declared capability flags via
88/// [`check_gated_outcome`], so a fixture cannot vacuously pass a scenario its
89/// capabilities claim to cover (#2258 review, F8 corpus-honesty batch).
90#[derive(Debug)]
91pub enum ScenarioOutcome {
92    /// The scenario ran and its assertions held.
93    Ran(ScenarioReport),
94    /// The fixture lacks the sequence shape; nothing was asserted.
95    Skipped {
96        /// Stable scenario name.
97        name: &'static str,
98        /// Provider driver under test.
99        provider: &'static str,
100        /// Why the wire family cannot spell the shape.
101        reason: &'static str,
102    },
103}
104
105/// Streaming-relevant capability flags for one wire family's conformance
106/// suite: which optional sequence shapes the wire can spell.
107///
108/// Each flag mirrors an `Option` field on [`ProviderWireFixture`], and the
109/// only constructor is [`ProviderWireFixture::capabilities`] — suites never
110/// hand-write flags, so a flag structurally cannot drift from the wire
111/// fixture that backs it (it *is* the fixture's populated-field set).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub struct SuiteCapabilities {
114    /// The wire streams tool-call arguments incrementally
115    /// (`partial_tool_call_frames`).
116    pub partial_tool_args: bool,
117    /// The wire has a genuine terminal that can omit usage metrics
118    /// (`zero_usage_terminal_frames`).
119    pub zero_usage_terminal: bool,
120    /// The wire has a data-less terminal signal (`bare_terminal_frames`).
121    pub bare_terminal: bool,
122    /// A frame-level decode failure can be spelled (`malformed_frame`).
123    pub malformed_frame: bool,
124    /// An unknown event type can be spelled (`unknown_event_frame`).
125    pub unknown_event_frame: bool,
126    /// A known event with a schema-defective payload can be spelled
127    /// (`defective_known_frame`).
128    pub defective_known_frame: bool,
129    /// The wire has a delta-less choice prelude shape
130    /// (`delta_less_prelude_frame`).
131    pub delta_less_prelude: bool,
132    /// The wire has a refusal channel (`refusal`).
133    pub refusal: bool,
134    /// The wire mints a constant per-stream reasoning identity, so
135    /// interleaving output is its only reasoning boundary
136    /// (`interleaved_reasoning`).
137    pub interleaved_reasoning: bool,
138}
139
140impl SuiteCapabilities {
141    /// Build a capability set from manifest names. An unknown name is an
142    /// error so a typo in a suite's `manifest:` list fails loudly rather
143    /// than silently asserting an empty flag; the macro-expanded test
144    /// asserts on it.
145    pub fn from_names(names: &[&str]) -> Result<Self, String> {
146        let mut caps = Self::default();
147        for name in names {
148            match *name {
149                "partial_tool_args" => caps.partial_tool_args = true,
150                "zero_usage_terminal" => caps.zero_usage_terminal = true,
151                "bare_terminal" => caps.bare_terminal = true,
152                "malformed_frame" => caps.malformed_frame = true,
153                "unknown_event_frame" => caps.unknown_event_frame = true,
154                "defective_known_frame" => caps.defective_known_frame = true,
155                "delta_less_prelude" => caps.delta_less_prelude = true,
156                "refusal" => caps.refusal = true,
157                "interleaved_reasoning" => caps.interleaved_reasoning = true,
158                other => {
159                    return Err(format!(
160                        "unknown capability name in suite manifest: {other}"
161                    ));
162                }
163            }
164        }
165        Ok(caps)
166    }
167}
168
169/// The canonical fixture-driven scenario set every wire-family suite must
170/// expand — one named test each, compared against the macro's emitted list by
171/// its `suite_is_complete` test (langchain's anti-tamper precedent).
172pub const CANONICAL_SCENARIOS: &[&str] = &[
173    "truncation_preserves_content_without_terminal",
174    "transport_error_after_tool_call_yields_err_then_end",
175    "malformed_frame_ends_the_reply",
176    "unknown_event_is_skipped",
177    "defective_known_event_ends_the_reply",
178    "delta_less_choice_prelude_is_a_noop",
179    "refusal_frames_deliver_text_without_error",
180    "bare_terminal_after_only_unparseable_frames_fabricates_nothing",
181    "usage_variants_are_reported_or_absent",
182    "interleaved_constant_id_reasoning_preserves_order",
183];
184
185/// Every streaming wire family in the workspace. The workspace registry test
186/// (`all_wire_families_have_conformance_suites`) fails CI when any family
187/// lacks a [`streaming_conformance_suite!`](crate::streaming_conformance_suite)
188/// invocation naming it.
189pub const WIRE_FAMILIES: &[&str] = &[
190    "openai_chat",
191    "openai_responses",
192    "openai_responses_websocket",
193    "chatgpt",
194    "anthropic",
195    "gemini_rest",
196    "gemini_interactions",
197    "gemini_grpc",
198    "xai",
199    "copilot",
200    "bedrock",
201    "candle",
202];
203
204/// The sanctioned reason for an expected-failure scenario, from `xfail`
205/// entries of the form `"scenario_name: reason (finding reference)"`.
206pub fn xfail_reason<'a>(xfail: &[&'a str], scenario: &str) -> Option<&'a str> {
207    xfail.iter().find_map(|entry| {
208        let (name, reason) = entry.split_once(':')?;
209        (name.trim() == scenario).then(|| reason.trim())
210    })
211}
212
213/// `xfail` entries that do not name a canonical scenario or carry no reason.
214pub fn invalid_xfail_entries(xfail: &[&str]) -> Vec<String> {
215    xfail
216        .iter()
217        .filter(|entry| match entry.split_once(':') {
218            Some((name, reason)) => {
219                !CANONICAL_SCENARIOS.contains(&name.trim()) || reason.trim().is_empty()
220            }
221            None => true,
222        })
223        .map(std::string::ToString::to_string)
224        .collect()
225}
226
227/// Enforce a capability-gated scenario's outcome against the suite's declared
228/// capability flag and its `xfail` list.
229///
230/// A `Skipped` outcome passes only when the capability is disclaimed; a `Ran`
231/// outcome passes only when it is declared — so a vacuous pass (fixture lacks
232/// the shape but the suite claims to cover it) is impossible, and the skip is
233/// visible in the test output.
234pub fn check_gated_outcome(
235    scenario: &'static str,
236    capability: bool,
237    xfail: &[&str],
238    outcome: Result<ScenarioOutcome, ConformanceError>,
239) -> Result<(), String> {
240    match (xfail_reason(xfail, scenario), outcome) {
241        (Some(reason), Err(error)) => {
242            eprintln!("xfail {scenario}: {reason} ({error})");
243            Ok(())
244        }
245        (Some(reason), Ok(_)) => Err(format!(
246            "{scenario} passed but is listed as xfail ({reason}); remove the xfail entry"
247        )),
248        (None, Err(error)) => Err(format!("{scenario} failed: {error}")),
249        (None, Ok(ScenarioOutcome::Ran(_))) => {
250            if capability {
251                Ok(())
252            } else {
253                Err(format!(
254                    "{scenario} ran but the suite disclaims the capability; set the flag to true"
255                ))
256            }
257        }
258        (None, Ok(ScenarioOutcome::Skipped { reason, .. })) => {
259            if capability {
260                Err(format!(
261                    "{scenario} skipped ({reason}) but the suite declares the capability; \
262                     a declared capability's scenario must run"
263                ))
264            } else {
265                eprintln!("skipped {scenario}: {reason}");
266                Ok(())
267            }
268        }
269    }
270}
271
272/// Enforce an always-runnable scenario's result against the `xfail` list.
273pub fn check_ungated_outcome(
274    scenario: &'static str,
275    xfail: &[&str],
276    result: Result<ScenarioReport, ConformanceError>,
277) -> Result<(), String> {
278    // A run always declares its capability.
279    check_gated_outcome(scenario, true, xfail, result.map(ScenarioOutcome::Ran))
280}
281
282/// One scripted wire input frame.
283///
284/// Byte-transport wires (SSE, NDJSON, websocket) script raw bytes fed through
285/// the provider's HTTP layer; typed-event wires (bedrock, candle,
286/// gemini-grpc) script already-typed SDK events fed to the adapter directly —
287/// events-first, no mock transport — which the typed driver downcasts back.
288#[derive(Clone)]
289pub enum WireInput {
290    /// A raw wire byte frame.
291    Bytes(Bytes),
292    /// An already-typed SDK event for a typed-event wire.
293    Event(std::sync::Arc<dyn std::any::Any + Send + Sync>),
294}
295
296impl WireInput {
297    /// The frame's raw bytes, when it is a byte frame.
298    pub fn as_bytes(&self) -> Option<&Bytes> {
299        match self {
300            Self::Bytes(bytes) => Some(bytes),
301            Self::Event(_) => None,
302        }
303    }
304
305    /// The frame's typed event, when it is an event frame of type `T`.
306    pub fn downcast_event<T: 'static>(&self) -> Option<&T> {
307        match self {
308            Self::Bytes(_) => None,
309            Self::Event(event) => event.downcast_ref(),
310        }
311    }
312}
313
314impl From<Bytes> for WireInput {
315    fn from(bytes: Bytes) -> Self {
316        Self::Bytes(bytes)
317    }
318}
319
320impl std::fmt::Debug for WireInput {
321    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        match self {
323            Self::Bytes(bytes) => formatter.debug_tuple("Bytes").field(bytes).finish(),
324            Self::Event(_) => formatter.write_str("Event(..)"),
325        }
326    }
327}
328
329/// Build a typed-event fixture frame.
330pub fn event_frame<T: Send + Sync + 'static>(event: T) -> WireInput {
331    WireInput::Event(std::sync::Arc::new(event))
332}
333
334/// The wire frames a driver feeds into the provider's pipeline. An `Err`
335/// chunk models a mid-stream transport failure.
336pub type WireChunks = Vec<http_client::Result<WireInput>>;
337
338/// Build the chunk list for an all-delivered frame sequence.
339pub fn ok_chunks(frames: impl IntoIterator<Item = impl Into<WireInput>>) -> WireChunks {
340    frames.into_iter().map(|frame| Ok(frame.into())).collect()
341}
342
343/// A scripted mid-stream transport failure chunk: the connection dropped,
344/// so there is no reply and no status.
345pub fn transport_error_chunk() -> http_client::Result<WireInput> {
346    Err(http_client::Error::instance(std::io::Error::new(
347        std::io::ErrorKind::ConnectionReset,
348        "connection reset",
349    )))
350}
351
352/// Executable stream-lifecycle validator (#2258 C1).
353///
354/// The invariants every normalized stream must satisfy, stated once and run
355/// over every recorded cassette and corpus fixture that drains through
356/// [`fixtures::drain`] — the langchain `assert_valid_event_stream` move:
357/// prose contracts scattered across N adapters become one executable
358/// artifact. Panics with the violated law.
359///
360/// Laws (universal — they hold for truncated and errored streams too):
361///
362/// 1. **Terminal error.** An error is the stream's last item: nothing
363///    follows it.
364/// 2. **Text conservation.** The aggregated text is exactly the
365///    concatenation of the yielded text deltas: accumulated delta content
366///    equals the payload the aggregate delivers.
367/// 3. **Completed-call conservation.** Every completed tool call yielded on
368///    the stream appears in the aggregated choice exactly once, and vice
369///    versa (counts match; aggregation neither drops nor duplicates).
370/// 4. **Sequence.** The events read back through
371///    [`StreamRecord::parse_prefix`](crate::streaming::StreamRecord::parse_prefix):
372///    each part starts once, in position, grows only while open, and ends
373///    once.
374/// 5. **Reasoning provenance.** The response contains a reasoning part only
375///    if the stream ended one.
376pub fn assert_valid_event_stream(
377    items: &[Result<Item<StreamEvent>, ErrorReport>],
378    choice: &[AssistantContent],
379) {
380    use crate::message::AssistantContent;
381
382    // Law 1: an error is the last item.
383    if let Some(error_index) = items.iter().position(Result::is_err) {
384        assert_eq!(
385            error_index + 1,
386            items.len(),
387            "law 1 (terminal error): an item followed the stream's error"
388        );
389    }
390    let events: Vec<&StreamEvent> = items
391        .iter()
392        .filter_map(|item| match item {
393            Ok(Item::Event(event)) => Some(event),
394            _ => None,
395        })
396        .collect();
397
398    // Law 2: text conservation.
399    let streamed_text: String = events
400        .iter()
401        .filter_map(|event| match event {
402            StreamEvent::Text { text, .. } => Some(text.as_str()),
403            _ => None,
404        })
405        .collect();
406    let aggregated_text: String = choice
407        .iter()
408        .filter_map(|content| match content {
409            AssistantContent::Text(text) => Some(text.text.as_str()),
410            _ => None,
411        })
412        .collect();
413    assert_eq!(
414        aggregated_text, streamed_text,
415        "law 2 (text conservation): aggregated text differs from the streamed fragments"
416    );
417
418    // Law 3: completed-call conservation.
419    let yielded_calls = events
420        .iter()
421        .filter(|event| {
422            matches!(
423                event,
424                StreamEvent::End {
425                    content: AssistantContent::ToolCall(_),
426                    ..
427                }
428            )
429        })
430        .count();
431    let aggregated_calls = choice
432        .iter()
433        .filter(|content| matches!(content, AssistantContent::ToolCall(_)))
434        .count();
435    assert_eq!(
436        aggregated_calls, yielded_calls,
437        "law 3 (completed-call conservation): {yielded_calls} calls yielded, \
438         {aggregated_calls} aggregated"
439    );
440
441    // Law 4: the sequence reads back.
442    let serialized = serde_json::to_value(
443        items
444            .iter()
445            .filter_map(|item| item.as_ref().ok())
446            .collect::<Vec<_>>(),
447    )
448    .unwrap_or_default();
449    let read_back = crate::streaming::StreamRecord::parse_prefix(serialized);
450    assert!(
451        read_back.is_ok(),
452        "law 4 (sequence): the stream's events do not read back: {read_back:?}"
453    );
454
455    // Law 5: reasoning provenance.
456    let yielded_reasoning = events.iter().any(|event| {
457        matches!(
458            event,
459            StreamEvent::End {
460                content: AssistantContent::Reasoning(_),
461                ..
462            }
463        )
464    });
465    let aggregated_reasoning = choice
466        .iter()
467        .any(|content| matches!(content, AssistantContent::Reasoning(_)));
468    assert!(
469        yielded_reasoning || !aggregated_reasoning,
470        "law 5 (reasoning provenance): aggregated reasoning with no reasoning yielded"
471    );
472}
473
474/// Everything the consumer observed from one full pipeline run: the yielded
475/// items in order, plus the response they folded into.
476#[derive(Debug)]
477pub struct DrainedStream {
478    /// Every item the stream yielded, in order.
479    pub items: Vec<Result<Item<StreamEvent>, ErrorReport>>,
480    /// The parts that ended, in position: the response's choice, or what
481    /// arrived before the stream stopped.
482    pub choice: Vec<AssistantContent>,
483    /// The response, absent on truncation or a terminal error.
484    pub response: Option<CompletionResponse>,
485}
486
487impl DrainedStream {
488    fn events(&self) -> impl Iterator<Item = &StreamEvent> {
489        self.items.iter().filter_map(|item| match item {
490            Ok(Item::Event(event)) => Some(event),
491            _ => None,
492        })
493    }
494
495    /// Text fragments yielded to the consumer, in order.
496    pub fn texts(&self) -> Vec<&str> {
497        self.events()
498            .filter_map(|event| match event {
499                StreamEvent::Text { text, .. } => Some(text.as_str()),
500                _ => None,
501            })
502            .collect()
503    }
504
505    /// Names of the tool calls that ended on the stream, in order.
506    pub fn tool_call_names(&self) -> Vec<&str> {
507        self.events()
508            .filter_map(|event| match event {
509                StreamEvent::End {
510                    content: AssistantContent::ToolCall(tool_call),
511                    ..
512                } => Some(tool_call.function.name.as_str()),
513                _ => None,
514            })
515            .collect()
516    }
517
518    /// Raw payloads of the `Unknown` items the stream yielded, in order.
519    pub fn unknown_values(&self) -> Vec<&serde_json::Value> {
520        self.items
521            .iter()
522            .filter_map(|item| match item {
523                Ok(Item::Unknown(value)) => Some(value.value()),
524                _ => None,
525            })
526            .collect()
527    }
528
529    /// Number of `Err` items the stream yielded.
530    pub fn error_count(&self) -> usize {
531        self.items.iter().filter(|item| item.is_err()).count()
532    }
533
534    /// Whether the provider ended the reply: it has a response.
535    fn has_terminal(&self) -> bool {
536        self.response.is_some()
537    }
538
539    /// Whether the stream reached the provider's end with no `Err` item —
540    /// the clean-completion precondition the content-shape scenarios share.
541    fn completed_cleanly(&self) -> bool {
542        self.error_count() == 0 && self.response.is_some()
543    }
544
545    /// Index of the first `Err` item, if any.
546    fn first_error_index(&self) -> Option<usize> {
547        self.items.iter().position(std::result::Result::is_err)
548    }
549
550    /// Text parts in the aggregated choice, in order.
551    pub fn choice_texts(&self) -> Vec<&str> {
552        self.choice
553            .iter()
554            .filter_map(|content| match content {
555                AssistantContent::Text(text) => Some(text.text.as_str()),
556                _ => None,
557            })
558            .collect()
559    }
560
561    /// Reasoning parts in the aggregated choice, in order.
562    pub fn choice_reasoning(&self) -> Vec<&crate::message::Reasoning> {
563        self.choice
564            .iter()
565            .filter_map(|content| match content {
566                AssistantContent::Reasoning(reasoning) => Some(reasoning),
567                _ => None,
568            })
569            .collect()
570    }
571}
572
573type DriveFn = Box<
574    dyn Fn(WireChunks) -> BoxFuture<'static, Result<DrainedStream, ProviderError>> + Send + Sync,
575>;
576
577/// One provider's full streaming pipeline over scripted wire chunks.
578///
579/// The closure builds a fresh provider client over a scripted HTTP double
580/// (`SequencedStreamingHttpClient`), opens `Model::stream`, drains
581/// it, and returns everything the consumer observed.
582pub struct WireDriver {
583    /// Stable descriptor name of the provider under test.
584    pub provider: &'static str,
585    drive: DriveFn,
586}
587
588impl WireDriver {
589    /// Wrap a provider pipeline closure.
590    pub fn new(
591        provider: &'static str,
592        drive: impl Fn(WireChunks) -> BoxFuture<'static, Result<DrainedStream, ProviderError>>
593        + Send
594        + Sync
595        + 'static,
596    ) -> Self {
597        Self {
598            provider,
599            drive: Box::new(drive),
600        }
601    }
602
603    /// Run the provider's full pipeline over `chunks` and drain it.
604    pub async fn drive(&self, chunks: WireChunks) -> Result<DrainedStream, ProviderError> {
605        (self.drive)(chunks).await
606    }
607}
608
609/// Refusal frames and the text the pipeline must deliver for them.
610pub struct RefusalFixture {
611    /// Frames carrying the refusal content.
612    pub frames: Vec<WireInput>,
613    /// Text the consumer must observe.
614    pub expected_text: &'static str,
615}
616
617/// The interleaving-boundary shape for a wire whose reasoning identity is
618/// a constant per-stream minted key: reasoning, an interleaved tool call,
619/// then more reasoning, which must aggregate as three ordered parts —
620/// never one merged item that misorders history on replay.
621pub struct InterleavedReasoningFixture {
622    /// Reasoning → tool call → reasoning frames, terminal included.
623    pub frames: Vec<WireInput>,
624    /// The reasoning content streamed before the boundary.
625    pub first_reasoning: &'static str,
626    /// The interleaved call's tool name.
627    pub tool_name: &'static str,
628    /// The reasoning content streamed after the boundary.
629    pub second_reasoning: &'static str,
630}
631
632type BufferedDriveFn = Box<
633    dyn Fn(String) -> BoxFuture<'static, Result<Vec<AssistantContent>, ProviderError>>
634        + Send
635        + Sync,
636>;
637
638/// A buffered-body pipeline (the ChatGPT backend shape): the full SSE body is
639/// re-parsed after the fact and merged with the terminal response body.
640pub struct BufferedBodyDriver {
641    /// Stable descriptor name of the provider under test.
642    pub provider: &'static str,
643    drive: BufferedDriveFn,
644}
645
646impl BufferedBodyDriver {
647    /// Wrap a buffered pipeline closure.
648    pub fn new(
649        provider: &'static str,
650        drive: impl Fn(String) -> BoxFuture<'static, Result<Vec<AssistantContent>, ProviderError>>
651        + Send
652        + Sync
653        + 'static,
654    ) -> Self {
655        Self {
656            provider,
657            drive: Box::new(drive),
658        }
659    }
660
661    /// Run the buffered pipeline over a complete SSE body.
662    pub async fn drive(&self, body: String) -> Result<Vec<AssistantContent>, ProviderError> {
663        (self.drive)(body).await
664    }
665}
666
667/// Per-provider wire frames for the shared scenario set.
668///
669/// `Option` fields cover sequence shapes a wire family cannot spell (e.g.
670/// an NDJSON wire has no event types, so no "unknown event type" frame).
671pub struct ProviderWireFixture {
672    /// The provider's full pipeline.
673    pub driver: WireDriver,
674    /// Frames that deliver exactly the text deltas in `expected_texts`.
675    pub text_frames: Vec<WireInput>,
676    /// The text deltas `text_frames` delivers, in order.
677    pub expected_texts: Vec<&'static str>,
678    /// Frames that fully deliver one tool call (including any completion
679    /// signal the wire needs, but no stream terminal).
680    pub tool_call_frames: Vec<WireInput>,
681    /// Name of the tool call `tool_call_frames` delivers.
682    pub expected_tool_name: &'static str,
683    /// Frames that leave a tool call mid-arguments, where the wire streams
684    /// arguments incrementally.
685    pub partial_tool_call_frames: Option<Vec<WireInput>>,
686    /// The provider's genuine stream terminal, carrying usage.
687    pub terminal_frames: Vec<WireInput>,
688    /// Total tokens `terminal_frames` reports.
689    pub expected_usage_total: u64,
690    /// Finish reason `terminal_frames` reports.
691    pub expected_finish_reason: Option<FinishReason>,
692    /// A genuine terminal that reports no usage metrics at all.
693    pub zero_usage_terminal_frames: Option<Vec<WireInput>>,
694    /// A terminal signal that carries no data of its own (e.g. a bare
695    /// `[DONE]`), for wires that have one.
696    pub bare_terminal_frames: Option<Vec<WireInput>>,
697    /// A frame that fails the wire decode entirely. `None` only for
698    /// typed-event wires, whose SDK surfaces decode failures as transport
699    /// errors — a frame-level corrupt input cannot be spelled there.
700    pub malformed_frame: Option<WireInput>,
701    /// An event type this client does not know, for typed-event wires.
702    pub unknown_event_frame: Option<WireInput>,
703    /// A known event whose payload is schema-defective.
704    pub defective_known_frame: Option<WireInput>,
705    /// A delta-less choice prelude (the Azure `prompt_filter_results` shape).
706    pub delta_less_prelude_frame: Option<WireInput>,
707    /// Refusal content frames, where the wire has a refusal channel.
708    pub refusal: Option<RefusalFixture>,
709    /// The interleaving-boundary shape, where the wire's reasoning identity
710    /// is a constant per-stream minted key (its adapter synthesizes the
711    /// reasoning ends other output implies).
712    pub interleaved_reasoning: Option<InterleavedReasoningFixture>,
713}
714
715impl ProviderWireFixture {
716    /// The capability set this fixture's populated optional fields spell —
717    /// the descriptor the suite macro gates scenarios on.
718    ///
719    /// Deriving flags here (instead of hand-writing them per suite
720    /// invocation) makes flag/fixture drift structurally impossible: a shape
721    /// the fixture supplies is a declared capability, a shape it lacks is a
722    /// visible named skip, and there is nothing else to keep in sync.
723    pub fn capabilities(&self) -> SuiteCapabilities {
724        SuiteCapabilities {
725            partial_tool_args: self.partial_tool_call_frames.is_some(),
726            zero_usage_terminal: self.zero_usage_terminal_frames.is_some(),
727            bare_terminal: self.bare_terminal_frames.is_some(),
728            malformed_frame: self.malformed_frame.is_some(),
729            unknown_event_frame: self.unknown_event_frame.is_some(),
730            defective_known_frame: self.defective_known_frame.is_some(),
731            delta_less_prelude: self.delta_less_prelude_frame.is_some(),
732            refusal: self.refusal.is_some(),
733            interleaved_reasoning: self.interleaved_reasoning.is_some(),
734        }
735    }
736}
737
738fn concat_frames(parts: &[&[WireInput]]) -> Vec<WireInput> {
739    parts
740        .iter()
741        .flat_map(|frames| frames.iter().cloned())
742        .collect()
743}
744
745/// A scenario's identity and running notes, bound once.
746///
747/// The scenario name and the provider under test are facts of the *scenario*,
748/// not of each check inside it: every contract violation in a body reports
749/// the same pair. `Checks` holds them, so a check states only what must hold
750/// and a report states only what was observed.
751struct Checks {
752    name: &'static str,
753    provider: &'static str,
754    observations: Vec<String>,
755}
756
757impl Checks {
758    fn new(name: &'static str, provider: &'static str) -> Self {
759        Self {
760            name,
761            provider,
762            observations: Vec::new(),
763        }
764    }
765
766    /// This scenario's contract violation, for the `ok_or_else` shape.
767    fn fail(&self, details: impl Into<String>) -> ConformanceError {
768        ConformanceError::contract(self.name, self.provider, details)
769    }
770
771    /// Require a contract to hold. `details` names the violation and is
772    /// built only when the check fails.
773    fn require<D: Into<String>>(
774        &self,
775        held: bool,
776        details: impl FnOnce() -> D,
777    ) -> Result<(), ConformanceError> {
778        if held {
779            return Ok(());
780        }
781        Err(self.fail(details()))
782    }
783
784    /// Record a verified sub-case.
785    fn note(&mut self, observation: impl Into<String>) {
786        self.observations.push(observation.into());
787    }
788
789    /// The wire family cannot spell this sequence shape: an explicit, named
790    /// skip that [`check_gated_outcome`] cross-checks against the suite's
791    /// declared capabilities.
792    fn skip(&self, reason: &'static str) -> ScenarioOutcome {
793        ScenarioOutcome::Skipped {
794            name: self.name,
795            provider: self.provider,
796            reason,
797        }
798    }
799
800    fn report(self) -> ScenarioReport {
801        ScenarioReport {
802            name: self.name,
803            provider: self.provider,
804            observations: self.observations,
805        }
806    }
807
808    fn ran(self) -> ScenarioOutcome {
809        ScenarioOutcome::Ran(self.report())
810    }
811}
812
813/// Truncation at every position — EOF before content, mid-text, mid-tool-args,
814/// after a tool call's frames — must preserve the content that ended and
815/// never produce a terminal record.
816///
817/// Pins the truncation family from round one (`rig-2257-code-review-findings-ec9f2625.md`):
818/// EOF without the provider's end event must not synthesize a successful
819/// usage-less terminal.
820pub async fn truncation_preserves_content_without_terminal(
821    fixture: &ProviderWireFixture,
822) -> Result<ScenarioReport, ConformanceError> {
823    let mut checks = Checks::new(
824        "truncation_preserves_content_without_terminal",
825        fixture.driver.provider,
826    );
827
828    // EOF before any content.
829    let drained = fixture.driver.drive(Vec::new()).await?;
830    checks.require(
831        !drained.has_terminal(),
832        || "an empty stream must not synthesize a terminal record",
833    )?;
834    checks.note("EOF before content: no terminal");
835
836    // EOF after text deltas.
837    let drained = fixture
838        .driver
839        .drive(ok_chunks(fixture.text_frames.clone()))
840        .await?;
841    checks.require(drained.texts() == fixture.expected_texts, || {
842        format!(
843            "text delivered before truncation must be preserved: expected {:?}, observed {:?}",
844            fixture.expected_texts,
845            drained.texts()
846        )
847    })?;
848    checks.require(
849        !drained.has_terminal(),
850        || "EOF after text deltas must not synthesize a terminal record",
851    )?;
852    checks.note("EOF mid-text: content preserved, no terminal");
853
854    // EOF mid-tool-arguments, where the wire streams arguments.
855    if let Some(partial) = &fixture.partial_tool_call_frames {
856        let drained = fixture.driver.drive(ok_chunks(partial.clone())).await?;
857        checks.require(
858            !drained.has_terminal(),
859            || "EOF mid-tool-arguments must not synthesize a terminal record",
860        )?;
861        checks.note("EOF mid-tool-args: no terminal");
862    }
863
864    // EOF after a tool call's frames, before the provider's end. A call is
865    // visible once it closes: on a wire that closes calls at the end, the
866    // cut call never surfaces.
867    let drained = fixture
868        .driver
869        .drive(ok_chunks(fixture.tool_call_frames.clone()))
870        .await?;
871    checks.require(
872        drained
873            .tool_call_names()
874            .iter()
875            .all(|name| *name == fixture.expected_tool_name),
876        || {
877            format!(
878                "only the delivered call may surface: observed {:?}",
879                drained.tool_call_names()
880            )
881        },
882    )?;
883    checks.require(
884        !drained.has_terminal(),
885        || "EOF after a tool call must not synthesize a terminal record",
886    )?;
887    checks.note("EOF after a tool call: no terminal");
888
889    Ok(checks.report())
890}
891
892/// A transport failure after a tool call's frames ends the reply: the `Err`
893/// is the last item, and there is no terminal record. A call the wire closed
894/// before the failure precedes it; one still open never surfaces.
895pub async fn transport_error_after_tool_call_yields_err_then_end(
896    fixture: &ProviderWireFixture,
897) -> Result<ScenarioReport, ConformanceError> {
898    let mut checks = Checks::new(
899        "transport_error_after_tool_call_yields_err_then_end",
900        fixture.driver.provider,
901    );
902
903    let mut chunks = ok_chunks(fixture.tool_call_frames.clone());
904    chunks.push(transport_error_chunk());
905    let drained = fixture.driver.drive(chunks).await?;
906
907    checks.require(
908        drained
909            .tool_call_names()
910            .iter()
911            .all(|name| *name == fixture.expected_tool_name),
912        || {
913            format!(
914                "only the delivered call may precede the transport error: observed {:?}",
915                drained.tool_call_names()
916            )
917        },
918    )?;
919    let error_index = drained
920        .first_error_index()
921        .ok_or_else(|| checks.fail("the transport failure must reach the consumer"))?;
922    checks.require(
923        error_index + 1 == drained.items.len(),
924        || "nothing may follow the terminal transport error",
925    )?;
926    checks.require(
927        !drained.has_terminal(),
928        || "a transport failure must not be papered over with a terminal record",
929    )?;
930
931    checks.note("Err, then end; no terminal");
932    Ok(checks.report())
933}
934
935/// A malformed frame between valid content and the provider's end ends the
936/// reply: it surfaces as the stream's last item, content before it is kept,
937/// and there is no response.
938pub async fn malformed_frame_ends_the_reply(
939    fixture: &ProviderWireFixture,
940) -> Result<ScenarioOutcome, ConformanceError> {
941    let mut checks = Checks::new("malformed_frame_ends_the_reply", fixture.driver.provider);
942    let Some(malformed) = &fixture.malformed_frame else {
943        return Ok(checks.skip("wire family cannot spell a frame-level decode failure"));
944    };
945
946    let frames = concat_frames(&[
947        &fixture.text_frames,
948        std::slice::from_ref(malformed),
949        &fixture.terminal_frames,
950    ]);
951    let drained = fixture.driver.drive(ok_chunks(frames)).await?;
952
953    checks.require(drained.error_count() == 1, || {
954        format!(
955            "the malformed frame must surface as exactly one Err item, observed {}",
956            drained.error_count()
957        )
958    })?;
959    checks.require(
960        drained.first_error_index() == Some(drained.items.len() - 1),
961        || "the malformed frame's error must be the stream's last item",
962    )?;
963    checks.require(
964        drained.texts() == fixture.expected_texts,
965        || "content before the malformed frame must be preserved",
966    )?;
967    checks.require(
968        !drained.has_terminal(),
969        || "a reply cut by a corrupt frame has no response",
970    )?;
971
972    checks.note("Err surfaced last; content kept; no response");
973    Ok(checks.ran())
974}
975
976/// An event type the client does not know must be skipped without an error,
977/// and the stream must still complete.
978///
979/// Pins the unknown-event forward-compatibility policy (round three,
980/// `rig-2257-code-review-findings-8a2f41c7.md`).
981pub async fn unknown_event_is_skipped(
982    fixture: &ProviderWireFixture,
983) -> Result<ScenarioOutcome, ConformanceError> {
984    let mut checks = Checks::new("unknown_event_is_skipped", fixture.driver.provider);
985    let Some(unknown) = &fixture.unknown_event_frame else {
986        return Ok(checks.skip("wire family cannot spell an unknown event type"));
987    };
988
989    let frames = concat_frames(&[
990        &fixture.text_frames,
991        std::slice::from_ref(unknown),
992        &fixture.terminal_frames,
993    ]);
994    let drained = fixture.driver.drive(ok_chunks(frames)).await?;
995
996    checks.require(
997        drained.error_count() == 0,
998        || "an unknown event type must be skipped, not surfaced as an error",
999    )?;
1000    checks.require(
1001        drained.texts() == fixture.expected_texts && drained.response.is_some(),
1002        || "the stream must deliver its content and complete around the skipped event",
1003    )?;
1004    // The frame is skipped semantically but observable verbatim on the raw
1005    // passthrough channel (openai-agents' raw-event precedent, #2258 item 5).
1006    checks.require(drained.unknown_values().len() == 1, || {
1007        format!(
1008            "exactly one Unknown passthrough item must surface for the unknown frame, \
1009             observed {}",
1010            drained.unknown_values().len()
1011        )
1012    })?;
1013
1014    // Control run without the unknown frame: the aggregated assistant choice
1015    // must be byte-identical — the passthrough item is never folded in.
1016    let control_frames = concat_frames(&[&fixture.text_frames, &fixture.terminal_frames]);
1017    let control = fixture.driver.drive(ok_chunks(control_frames)).await?;
1018    checks.require(
1019        drained.choice == control.choice,
1020        || "the unknown frame must not perturb the aggregated assistant choice",
1021    )?;
1022
1023    checks.note(
1024        "unknown event skipped semantically, surfaced on the raw channel, \
1025         choice unchanged, stream completed",
1026    );
1027    Ok(checks.ran())
1028}
1029
1030/// A *known* event whose payload is schema-defective ends the reply: it
1031/// surfaces as the stream's last item, and there is no response.
1032///
1033/// Pins the round-5 known-type strictness policy (a defective known event is
1034/// never demoted to an unknown one).
1035pub async fn defective_known_event_ends_the_reply(
1036    fixture: &ProviderWireFixture,
1037) -> Result<ScenarioOutcome, ConformanceError> {
1038    let mut checks = Checks::new(
1039        "defective_known_event_ends_the_reply",
1040        fixture.driver.provider,
1041    );
1042    let Some(defective) = &fixture.defective_known_frame else {
1043        return Ok(
1044            checks.skip("wire family cannot spell a known event with a schema-defective payload")
1045        );
1046    };
1047
1048    let frames = concat_frames(&[
1049        &fixture.text_frames,
1050        std::slice::from_ref(defective),
1051        &fixture.terminal_frames,
1052    ]);
1053    let drained = fixture.driver.drive(ok_chunks(frames)).await?;
1054
1055    checks.require(drained.error_count() == 1, || {
1056        format!(
1057            "a known event with a schema defect must surface exactly one Err item, observed {}",
1058            drained.error_count()
1059        )
1060    })?;
1061    checks.require(
1062        !drained.has_terminal(),
1063        || "a reply cut by a defective event has no response",
1064    )?;
1065
1066    checks.note("defective known event ended the reply with its Err");
1067    Ok(checks.ran())
1068}
1069
1070/// A delta-less choice (the Azure `prompt_filter_results` prelude) must be a
1071/// no-op — no error, no content, and the rest of the stream unaffected.
1072///
1073/// Pins the Azure prelude no-op from round two
1074/// (`rig-2257-code-review-findings-b91d03aa.md`).
1075pub async fn delta_less_choice_prelude_is_a_noop(
1076    fixture: &ProviderWireFixture,
1077) -> Result<ScenarioOutcome, ConformanceError> {
1078    let mut checks = Checks::new(
1079        "delta_less_choice_prelude_is_a_noop",
1080        fixture.driver.provider,
1081    );
1082    let Some(prelude) = &fixture.delta_less_prelude_frame else {
1083        return Ok(checks.skip("wire family has no delta-less prelude shape"));
1084    };
1085
1086    let frames = concat_frames(&[
1087        std::slice::from_ref(prelude),
1088        &fixture.text_frames,
1089        &fixture.terminal_frames,
1090    ]);
1091    let drained = fixture.driver.drive(ok_chunks(frames)).await?;
1092
1093    checks.require(
1094        drained.error_count() == 0,
1095        || "the delta-less prelude must not surface an error",
1096    )?;
1097    checks.require(
1098        drained.texts() == fixture.expected_texts && drained.response.is_some(),
1099        || "the prelude must not perturb content delivery or the terminal",
1100    )?;
1101
1102    checks.note("delta-less prelude ignored; stream unaffected");
1103    Ok(checks.ran())
1104}
1105
1106/// Refusal frames must deliver their text to the consumer without an error.
1107///
1108/// Pins the refusal-delta handling from round three
1109/// (`rig-2257-code-review-findings-8a2f41c7.md`).
1110pub async fn refusal_frames_deliver_text_without_error(
1111    fixture: &ProviderWireFixture,
1112) -> Result<ScenarioOutcome, ConformanceError> {
1113    let mut checks = Checks::new(
1114        "refusal_frames_deliver_text_without_error",
1115        fixture.driver.provider,
1116    );
1117    let Some(refusal) = &fixture.refusal else {
1118        return Ok(checks.skip("wire family has no refusal channel"));
1119    };
1120
1121    let frames = concat_frames(&[&refusal.frames, &fixture.terminal_frames]);
1122    let drained = fixture.driver.drive(ok_chunks(frames)).await?;
1123
1124    checks.require(
1125        drained.error_count() == 0,
1126        || "refusal content must not surface as an error",
1127    )?;
1128    let delivered = drained.texts().concat();
1129    checks.require(delivered == refusal.expected_text, || {
1130        format!(
1131            "refusal text must be delivered: expected {:?}, observed {delivered:?}",
1132            refusal.expected_text
1133        )
1134    })?;
1135    checks.require(
1136        drained.response.is_some(),
1137        || "a refused turn still ends with the provider's genuine terminal",
1138    )?;
1139
1140    checks.note("refusal text delivered without error");
1141    Ok(checks.ran())
1142}
1143
1144/// On the buffered-body pipeline (the ChatGPT backend), a terminal whose body
1145/// carries text never seen as a delta must merge that text into the choice
1146/// exactly once, and a body restating streamed deltas must not duplicate them.
1147///
1148/// Pins the terminal-body/delta per-kind merge from round five
1149/// (`rig-2257-code-review-findings-5c73639c.md`) and the empty-delta merge
1150/// direction verified in round six (`rig-2257-code-review-findings-34ee8ba5.md`
1151/// P3-2).
1152pub async fn terminal_body_content_merges_per_kind(
1153    driver: &BufferedBodyDriver,
1154    cases: Vec<(&'static str, String)>,
1155    expected_text: &str,
1156) -> Result<ScenarioReport, ConformanceError> {
1157    let mut checks = Checks::new("terminal_body_content_merges_per_kind", driver.provider);
1158
1159    for (label, body) in cases {
1160        let choice = driver.drive(body).await?;
1161        let choice_text: String = choice
1162            .iter()
1163            .filter_map(|content| match content {
1164                AssistantContent::Text(text) => Some(text.text.as_str()),
1165                _ => None,
1166            })
1167            .collect();
1168        let occurrences = choice_text.matches(expected_text).count();
1169        checks.require(occurrences == 1, || {
1170            format!(
1171                "{label}: terminal-body text must appear exactly once in the choice, observed {occurrences} in {choice_text:?}"
1172            )
1173        })?;
1174        checks.note(format!("{label}: text merged exactly once"));
1175    }
1176
1177    Ok(checks.report())
1178}
1179
1180/// A bare terminal signal after only-unparseable frames must not fabricate a
1181/// successful terminal record: the parse errors were already surfaced, and a
1182/// default-usage terminal would dress the failure up as success.
1183///
1184/// Pins the bare-`[DONE]` guard from round six
1185/// (`rig-2257-code-review-findings-5c73639c.md`, carried into `34ee8ba5`).
1186pub async fn bare_terminal_after_only_unparseable_frames_fabricates_nothing(
1187    fixture: &ProviderWireFixture,
1188) -> Result<ScenarioOutcome, ConformanceError> {
1189    let mut checks = Checks::new(
1190        "bare_terminal_after_only_unparseable_frames_fabricates_nothing",
1191        fixture.driver.provider,
1192    );
1193    let Some(bare_terminal) = &fixture.bare_terminal_frames else {
1194        return Ok(checks.skip("wire family has no data-less terminal signal"));
1195    };
1196    let Some(malformed) = &fixture.malformed_frame else {
1197        return Ok(checks.skip("wire family cannot spell a frame-level decode failure"));
1198    };
1199
1200    let frames = concat_frames(&[std::slice::from_ref(malformed), bare_terminal]);
1201    let drained = fixture.driver.drive(ok_chunks(frames)).await?;
1202
1203    checks.require(
1204        drained.error_count() != 0,
1205        || "the unparseable frame must surface as an Err item",
1206    )?;
1207    checks.require(
1208        !drained.has_terminal(),
1209        || "a bare terminal with no decoded frame must not fabricate a terminal record",
1210    )?;
1211
1212    checks.note("no fabricated terminal after only-unparseable frames");
1213    Ok(checks.ran())
1214}
1215
1216/// The genuine terminal must report the provider's usage; a terminal without
1217/// usage metrics must complete with no counter reported rather than being
1218/// suppressed or invented.
1219///
1220/// Pins the absent-usage contract on [`CompletionResponse::usage`](crate::completion::CompletionResponse::usage)
1221/// (round one, `rig-2257-code-review-findings-ec9f2625.md`).
1222pub async fn usage_variants_are_reported_or_absent(
1223    fixture: &ProviderWireFixture,
1224) -> Result<ScenarioReport, ConformanceError> {
1225    let mut checks = Checks::new(
1226        "usage_variants_are_reported_or_absent",
1227        fixture.driver.provider,
1228    );
1229
1230    let frames = concat_frames(&[&fixture.text_frames, &fixture.terminal_frames]);
1231    let drained = fixture.driver.drive(ok_chunks(frames)).await?;
1232    let response = drained
1233        .response
1234        .as_ref()
1235        .ok_or_else(|| checks.fail("the genuine terminal must produce a record"))?;
1236    checks.require(
1237        response.usage.total_tokens == Some(fixture.expected_usage_total),
1238        || {
1239            format!(
1240                "terminal usage must be preserved: expected total {}, observed {:?}",
1241                fixture.expected_usage_total, response.usage.total_tokens
1242            )
1243        },
1244    )?;
1245    checks.require(
1246        response.finish_reason() == fixture.expected_finish_reason,
1247        || {
1248            format!(
1249                "terminal finish reason must be normalized: expected {:?}, observed {:?}",
1250                fixture.expected_finish_reason,
1251                response.finish_reason()
1252            )
1253        },
1254    )?;
1255    checks.note(format!(
1256        "usage total {} and finish reason {:?} preserved",
1257        fixture.expected_usage_total, fixture.expected_finish_reason
1258    ));
1259
1260    if let Some(zero_usage) = &fixture.zero_usage_terminal_frames {
1261        let frames = concat_frames(&[&fixture.text_frames, zero_usage]);
1262        let drained = fixture.driver.drive(ok_chunks(frames)).await?;
1263        let response = drained.response.as_ref().ok_or_else(|| {
1264            checks.fail("a usage-less genuine terminal must still complete the stream")
1265        })?;
1266        checks.require(!response.usage.is_reported(), || {
1267            format!(
1268                "missing usage metrics must leave every counter unreported, not invented: {:?}",
1269                response.usage
1270            )
1271        })?;
1272        checks.note("usage-less terminal completed with no counter reported");
1273    }
1274
1275    Ok(checks.report())
1276}
1277
1278/// Reasoning-summary deltas followed by the item's full `output_item.done`
1279/// block must aggregate to the summary exactly once — the full block
1280/// supersedes its own deltas, never duplicates them.
1281///
1282/// Pins the open P1 in `rig-2257-code-review-findings-34ee8ba5.md` ("OpenAI
1283/// Responses reasoning-summary streams duplicate reasoning content"):
1284/// `reasoning_summary_text.delta` drops `item_id`, so the strict same-item
1285/// table appends the full block beside the delta-built item.
1286pub async fn reasoning_summary_deltas_are_superseded_without_duplication(
1287    driver: &WireDriver,
1288    frames: Vec<WireInput>,
1289    summary_text: &str,
1290) -> Result<ScenarioReport, ConformanceError> {
1291    let mut checks = Checks::new(
1292        "reasoning_summary_deltas_are_superseded_without_duplication",
1293        driver.provider,
1294    );
1295
1296    let drained = driver.drive(ok_chunks(frames)).await?;
1297    checks.require(
1298        drained.completed_cleanly(),
1299        || "the reasoning stream must complete without errors",
1300    )?;
1301    let reasoning = drained.choice_reasoning();
1302    let occurrences: usize = reasoning
1303        .iter()
1304        .map(|item| item.text.matches(summary_text).count())
1305        .sum();
1306    checks.require(occurrences == 1, || {
1307        format!(
1308            "the summary must appear exactly once in the aggregated choice, observed {occurrences} across {reasoning:?}"
1309        )
1310    })?;
1311    checks.require(reasoning.len() == 1, || {
1312        format!(
1313            "deltas and their full block must collapse to one reasoning item, observed {}",
1314            reasoning.len()
1315        )
1316    })?;
1317
1318    checks.note("summary aggregated exactly once");
1319    Ok(checks.report())
1320}
1321
1322/// A reasoning item whose `output_item.done` carries several parts under one
1323/// item id (summary parts, text, encrypted) must keep every part, in order:
1324/// the item is one block whose provider item holds them all.
1325pub async fn multi_part_same_id_reasoning_keeps_every_part(
1326    driver: &WireDriver,
1327    frames: Vec<WireInput>,
1328    expected_parts: &[&str],
1329) -> Result<ScenarioReport, ConformanceError> {
1330    let mut checks = Checks::new(
1331        "multi_part_same_id_reasoning_keeps_every_part",
1332        driver.provider,
1333    );
1334
1335    let drained = driver.drive(ok_chunks(frames)).await?;
1336    checks.require(
1337        drained.completed_cleanly(),
1338        || "the reasoning stream must complete without errors",
1339    )?;
1340    let items: Vec<String> = drained
1341        .choice_reasoning()
1342        .iter()
1343        .filter_map(|item| item.native.as_ref())
1344        .map(|native| native.item.to_string())
1345        .collect();
1346    let in_order = |item: &String| {
1347        let mut rest = item.as_str();
1348        expected_parts.iter().all(|part| match rest.find(part) {
1349            Some(at) => {
1350                rest = &rest[at + part.len()..];
1351                true
1352            }
1353            None => false,
1354        })
1355    };
1356    let observed = items;
1357    checks.require(observed.len() == 1 && observed.iter().all(in_order), || {
1358        format!(
1359            "every same-id reasoning part must survive in order: expected {expected_parts:?}, observed {observed:?}"
1360        )
1361    })?;
1362
1363    checks.note(format!(
1364        "all {} reasoning parts survived",
1365        expected_parts.len()
1366    ));
1367    Ok(checks.report())
1368}
1369
1370/// Reasoning deltas interleaved with a tool call, then the item's completed
1371/// block, must aggregate to exactly one reasoning item carrying the block's
1372/// content.
1373///
1374/// Pins the interleaved-reasoning replacement contract on
1375/// completed reasoning block (round six,
1376/// `rig-2257-code-review-findings-34ee8ba5.md`, "Verified sound" section).
1377pub async fn interleaved_reasoning_aggregates_to_one_item(
1378    driver: &WireDriver,
1379    frames: Vec<WireInput>,
1380    expected_text: &str,
1381) -> Result<ScenarioReport, ConformanceError> {
1382    let mut checks = Checks::new(
1383        "interleaved_reasoning_aggregates_to_one_item",
1384        driver.provider,
1385    );
1386
1387    let drained = driver.drive(ok_chunks(frames)).await?;
1388    checks.require(
1389        drained.completed_cleanly(),
1390        || "the interleaved stream must complete without errors",
1391    )?;
1392    let reasoning = drained.choice_reasoning();
1393    checks.require(reasoning.len() == 1, || {
1394        format!(
1395            "interleaved deltas and their completed block must collapse to one reasoning item, observed {}",
1396            reasoning.len()
1397        )
1398    })?;
1399    let carries_text = reasoning.iter().any(|item| item.text == expected_text);
1400    checks.require(carries_text, || {
1401        format!("the reasoning item must carry the completed block's text {expected_text:?}")
1402    })?;
1403
1404    checks.note("exactly one reasoning item with the completed content");
1405    Ok(checks.report())
1406}
1407
1408/// On a constant-id wire (a boundary-minted per-stream reasoning id), other
1409/// output closes the open reasoning item: thought → tool call → thought must
1410/// aggregate as `[Reasoning(first), ToolCall, Reasoning(second)]` — two items
1411/// in arrival order, never one merged item that misorders history on replay.
1412///
1413/// Pins the F1b ordering dimension of the #2258 review (main's
1414/// "other output closes the reasoning item" boundary, lost when identity
1415/// became the per-stream constant).
1416pub async fn interleaved_constant_id_reasoning_preserves_order(
1417    fixture: &ProviderWireFixture,
1418) -> Result<ScenarioOutcome, ConformanceError> {
1419    let mut checks = Checks::new(
1420        "interleaved_constant_id_reasoning_preserves_order",
1421        fixture.driver.provider,
1422    );
1423    let Some(interleaved) = &fixture.interleaved_reasoning else {
1424        return Ok(checks.skip("wire fixture supplies no interleaved reasoning frames"));
1425    };
1426
1427    let drained = fixture
1428        .driver
1429        .drive(ok_chunks(interleaved.frames.clone()))
1430        .await?;
1431    checks.require(
1432        drained.completed_cleanly(),
1433        || "the interleaved stream must complete without errors",
1434    )?;
1435    assert_reasoning_tool_reasoning(
1436        &checks,
1437        &drained,
1438        interleaved.first_reasoning,
1439        interleaved.tool_name,
1440        interleaved.second_reasoning,
1441    )?;
1442
1443    checks.note("boundary kept: reasoning, tool call, reasoning in order");
1444    Ok(checks.ran())
1445}
1446
1447/// On a constant-id wire whose completed reasoning block arrives as a signed
1448/// full restatement (gemini `thoughtSignature`), a full block *after*
1449/// interleaved output must not replace-and-discard the thought accumulated
1450/// before the boundary: the choice keeps `[Reasoning(first), ToolCall,
1451/// Reasoning(second, signed)]`.
1452///
1453/// Pins the F1b erasure dimension of the #2258 review, on top of the F1
1454/// adapter fix (the signed chunk restates only post-boundary fragments).
1455pub async fn interleaved_signed_full_reasoning_does_not_erase_prior_thought(
1456    driver: &WireDriver,
1457    frames: Vec<WireInput>,
1458    first: &str,
1459    tool_name: &str,
1460    second: &str,
1461) -> Result<ScenarioReport, ConformanceError> {
1462    let mut checks = Checks::new(
1463        "interleaved_signed_full_reasoning_does_not_erase_prior_thought",
1464        driver.provider,
1465    );
1466
1467    let drained = driver.drive(ok_chunks(frames)).await?;
1468    checks.require(
1469        drained.completed_cleanly(),
1470        || "the interleaved stream must complete without errors",
1471    )?;
1472    assert_reasoning_tool_reasoning(&checks, &drained, first, tool_name, second)?;
1473    let signed = drained
1474        .choice_reasoning()
1475        .last()
1476        .is_some_and(|reasoning| reasoning.native.is_some());
1477    checks.require(signed, || "the post-boundary block must keep its signature")?;
1478
1479    checks.note("pre-boundary thought survived; signed block completed the post-boundary part");
1480    Ok(checks.report())
1481}
1482
1483/// Shared assertion: the aggregated choice is exactly
1484/// `[Reasoning(first), ToolCall(tool_name), Reasoning(second…)]`.
1485fn assert_reasoning_tool_reasoning(
1486    checks: &Checks,
1487    drained: &DrainedStream,
1488    first: &str,
1489    tool_name: &str,
1490    second: &str,
1491) -> Result<(), ConformanceError> {
1492    let shape: Vec<String> = drained
1493        .choice
1494        .iter()
1495        .map(|content| match content {
1496            AssistantContent::Reasoning(reasoning) => format!("reasoning:{}", reasoning.text),
1497            AssistantContent::ToolCall(tool_call) => {
1498                format!("tool:{}", tool_call.function.name)
1499            }
1500            AssistantContent::Text(text) => format!("text:{}", text.text),
1501            AssistantContent::Image(_) => "image".to_string(),
1502            AssistantContent::Opaque(opaque) => format!("opaque:{}", opaque.kind().unwrap_or("")),
1503        })
1504        .collect();
1505    let expected = vec![
1506        format!("reasoning:{first}"),
1507        format!("tool:{tool_name}"),
1508        format!("reasoning:{second}"),
1509    ];
1510    checks.require(shape == expected, || {
1511        format!("the boundary must survive aggregation: expected {expected:?}, observed {shape:?}")
1512    })
1513}
1514
1515/// Per-provider wire fixtures for the shared scenario set.
1516pub mod fixtures {
1517    use super::*;
1518    use crate::driver::{Model, Transport};
1519    use crate::operation::Completion;
1520    use crate::test_utils::SequencedStreamingHttpClient;
1521    use crate::wire::Wire;
1522    use serde_json::json;
1523
1524    /// Drain a full normalized stream into everything the consumer observed.
1525    /// Public so provider-crate conformance suites (the typed-event wires)
1526    /// can reuse it in their drivers.
1527    pub async fn drain(mut stream: crate::streaming::CompletionStream) -> DrainedStream {
1528        let mut items = Vec::new();
1529        while let Some(item) = stream.next().await {
1530            items.push(item.map_err(|error| ErrorReport::from(&error)));
1531        }
1532        let partial = stream.partial().choice;
1533        let response = stream.finish().await.ok();
1534        let drained = DrainedStream {
1535            items,
1536            choice: response
1537                .as_ref()
1538                .map_or(partial, |response| response.choice.clone()),
1539            response,
1540        };
1541        // Every fixture and cassette that drains through this helper runs
1542        // the lifecycle validator — the prose invariants as one executable
1543        // artifact (#2258 C1).
1544        super::assert_valid_event_stream(&drained.items, &drained.choice);
1545        drained
1546    }
1547
1548    /// Drive `model` through the observed stream entry and drain it. Every
1549    /// wire threads the context it is handed: the trace must show the
1550    /// request it sent and how the attempt closed, or the wire has silently
1551    /// taken the context-discarding default.
1552    pub async fn drain_observed<W, T>(
1553        model: &Model<W, T>,
1554        request: crate::completion::CompletionRequest,
1555    ) -> Result<DrainedStream, ProviderError>
1556    where
1557        W: Wire<Op = Completion>,
1558        T: Transport<W>,
1559    {
1560        let log = std::sync::Arc::new(crate::observe::ObservationLog::default());
1561        let context = crate::observe::AdapterContext::new(
1562            log.clone(),
1563            crate::observe::Subject::default(),
1564            "conformance",
1565        );
1566        // A stream that fails to open still sent (or failed to send) a
1567        // request: the facts are asserted before the error propagates.
1568        let drained = match model.stream_observed(request, context) {
1569            Ok(stream) => Ok(drain(stream).await),
1570            Err(error) => Err(error),
1571        };
1572        let events: Vec<_> = log
1573            .trace()
1574            .observations
1575            .iter()
1576            .filter_map(|o| match &o.action {
1577                crate::observe::Action::Adapter { observation } => Some(observation.event.clone()),
1578                _ => None,
1579            })
1580            .collect();
1581        assert!(
1582            matches!(
1583                events.first(),
1584                Some(crate::observe::AdapterEvent::Started { .. })
1585            ),
1586            "the wire must attach the observation context it was handed: {events:?}"
1587        );
1588        assert!(
1589            matches!(
1590                events.last(),
1591                Some(crate::observe::AdapterEvent::Finished { .. })
1592            ),
1593            "the attempt must close: {events:?}"
1594        );
1595        drained
1596    }
1597
1598    /// Lower fixture frames onto the byte transport a `SequencedStreamingHttpClient`
1599    /// replays. Only byte frames are valid here — an event frame in a
1600    /// byte-driver fixture is a fixture authoring error.
1601    fn byte_chunks(chunks: WireChunks) -> Result<Vec<http_client::Result<Bytes>>, ProviderError> {
1602        chunks
1603            .into_iter()
1604            .map(|chunk| match chunk {
1605                Ok(WireInput::Bytes(bytes)) => Ok(Ok(bytes)),
1606                Ok(WireInput::Event(_)) => Err(ProviderError::Provider(
1607                    "typed-event frame fed to a byte-transport driver".to_string(),
1608                )),
1609                Err(error) => Ok(Err(error)),
1610            })
1611            .collect()
1612    }
1613
1614    /// Drive a byte-transport wire over the scripted chunks: bind the
1615    /// provider's wire to the replaying transport, open the model `bind`
1616    /// names, and drain the observed stream.
1617    ///
1618    /// Every byte-wire conformance driver is this walk; since the wire
1619    /// unification they differ only in which wire they name, so the walk is
1620    /// written once and each family supplies its own `bind`.
1621    fn byte_driver<W>(
1622        provider: &'static str,
1623        bind: fn(SequencedStreamingHttpClient) -> Model<W, SequencedStreamingHttpClient>,
1624    ) -> WireDriver
1625    where
1626        W: Wire<Op = Completion, Payload = crate::wire::Encoded, Frame = crate::wire::WireFrame>,
1627    {
1628        WireDriver::new(provider, move |chunks| {
1629            Box::pin(async move {
1630                let model = bind(SequencedStreamingHttpClient::new(byte_chunks(chunks)?));
1631                let request = CompletionRequest::new("hello");
1632                drain_observed(&model, request).await
1633            })
1634        })
1635    }
1636
1637    fn sse(frame: &serde_json::Value) -> WireInput {
1638        WireInput::Bytes(Bytes::from(format!("data: {frame}\n\n")))
1639    }
1640
1641    fn sse_raw(data: &str) -> WireInput {
1642        WireInput::Bytes(Bytes::from(format!("data: {data}\n\n")))
1643    }
1644
1645    /// The frame's SSE text, for buffered-body pipelines that re-parse a
1646    /// whole body string.
1647    fn frame_text(frame: &WireInput) -> String {
1648        frame
1649            .as_bytes()
1650            .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
1651            .unwrap_or_default()
1652    }
1653
1654    /// OpenAI chat-completions wire (the shared OpenAI-compatible SSE path).
1655    pub mod openai_chat {
1656        use super::*;
1657
1658        fn driver() -> WireDriver {
1659            byte_driver("openai", |transport| {
1660                crate::driver::Model::new(
1661                    crate::providers::openai::wire::OpenAIConfig::with_key(
1662                        &crate::providers::openai::wire::OPENAI,
1663                        "test-key",
1664                    )
1665                    .chat("gpt-4o"),
1666                    transport,
1667                )
1668            })
1669        }
1670
1671        /// The chat-completions fixture.
1672        pub fn fixture() -> ProviderWireFixture {
1673            ProviderWireFixture {
1674                driver: driver(),
1675                text_frames: vec![sse(&json!({
1676                    "id": "chatcmpl-1",
1677                    "model": "gpt-4o-2024-08-06",
1678                    "choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": null}],
1679                    "usage": null,
1680                }))],
1681                expected_texts: vec!["hi"],
1682                tool_call_frames: vec![
1683                    sse(&json!({
1684                        "choices": [{"index": 0, "delta": {"tool_calls": [{
1685                            "index": 0,
1686                            "id": "call_1",
1687                            "type": "function",
1688                            "function": {"name": "get_weather", "arguments": ""},
1689                        }]}, "finish_reason": null}],
1690                    })),
1691                    sse(&json!({
1692                        "choices": [{"index": 0, "delta": {"tool_calls": [{
1693                            "index": 0,
1694                            "function": {"arguments": "{\"city\":\"Tokyo\"}"},
1695                        }]}, "finish_reason": null}],
1696                    })),
1697                    // No `finish_reason` chunk: on the chat wire that IS the
1698                    // terminal signal, and these frames must stop short of it.
1699                    // EOF/error cleanup still flushes the completed call.
1700                ],
1701                expected_tool_name: "get_weather",
1702                partial_tool_call_frames: Some(vec![sse(&json!({
1703                    "choices": [{"index": 0, "delta": {"tool_calls": [{
1704                        "index": 0,
1705                        "id": "call_1",
1706                        "type": "function",
1707                        "function": {"name": "get_weather", "arguments": "{\"cit"},
1708                    }]}, "finish_reason": null}],
1709                }))]),
1710                terminal_frames: vec![
1711                    sse(&json!({
1712                        "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
1713                        "usage": null,
1714                    })),
1715                    sse(&json!({
1716                        "choices": [],
1717                        "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
1718                    })),
1719                    sse_raw("[DONE]"),
1720                ],
1721                expected_usage_total: 15,
1722                expected_finish_reason: Some(FinishReason::Stop),
1723                zero_usage_terminal_frames: Some(vec![
1724                    sse(&json!({
1725                        "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
1726                        "usage": null,
1727                    })),
1728                    sse_raw("[DONE]"),
1729                ]),
1730                bare_terminal_frames: Some(vec![sse_raw("[DONE]")]),
1731                malformed_frame: Some(sse_raw("{not json")),
1732                unknown_event_frame: None,
1733                // A wrongly-typed `content` is tolerated by the lenient delta
1734                // decode; a wrongly-typed `choices` is a genuine schema defect
1735                // of the known chunk shape.
1736                // The Chat decoder reads leniently, as pi does: `choices`
1737                // of another type is a chunk with no choices, not a defect.
1738                defective_known_frame: None,
1739                // The Azure `prompt_filter_results` prelude: a choice with no
1740                // `delta` at all.
1741                delta_less_prelude_frame: Some(sse_raw(
1742                    r#"{"id":"","object":"","choices":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"}}}]}"#,
1743                )),
1744                refusal: None,
1745                // Deliberately absent — a documented named skip, not a gap.
1746                // The chat wire streams tool calls as fragments that only
1747                // finalize at a boundary the wire itself signals (next slot,
1748                // finish_reason, terminal), so the AGGREGATED part order
1749                // cannot pin reasoning→tool→reasoning without risky early
1750                // finalization; and the chat request format erases part
1751                // order on replay regardless (`tool_calls` is a flat array
1752                // beside `content`). The boundary the adapter does own —
1753                // closing the open reasoning block before emitting tool
1754                // content — is pinned at emission level by the adapter's
1755                // unit tests and by the driver's debug-mode sequence laws.
1756                interleaved_reasoning: None,
1757            }
1758        }
1759    }
1760
1761    /// OpenAI Responses API wire.
1762    pub mod openai_responses {
1763        use super::*;
1764
1765        /// The driver alone, for the reasoning-specific scenarios.
1766        pub fn driver() -> WireDriver {
1767            byte_driver("openai", |transport| {
1768                crate::driver::Model::new(
1769                    crate::providers::openai::OpenAIConfig::new("test-key").responses("gpt-5.4"),
1770                    transport,
1771                )
1772            })
1773        }
1774
1775        fn completed_response(
1776            usage: Option<&serde_json::Value>,
1777            output: &serde_json::Value,
1778        ) -> serde_json::Value {
1779            json!({
1780                "id": "resp_1",
1781                "object": "response",
1782                "created_at": 0,
1783                "status": "completed",
1784                "model": "gpt-5.4",
1785                "output": output,
1786                "tools": [],
1787                "usage": usage,
1788            })
1789        }
1790
1791        fn terminal(usage: Option<&serde_json::Value>, output: &serde_json::Value) -> WireInput {
1792            sse(&json!({
1793                "type": "response.completed",
1794                "sequence_number": 99,
1795                "response": completed_response(usage, output),
1796            }))
1797        }
1798
1799        fn usage_json() -> serde_json::Value {
1800            json!({
1801                "input_tokens": 10,
1802                "output_tokens": 5,
1803                "output_tokens_details": {"reasoning_tokens": 0},
1804                "total_tokens": 15,
1805            })
1806        }
1807
1808        fn text_delta(text: &str) -> WireInput {
1809            sse(&json!({
1810                "type": "response.output_text.delta",
1811                "content_index": 0,
1812                "delta": text,
1813                "item_id": "msg_1",
1814                "output_index": 0,
1815                "sequence_number": 1,
1816            }))
1817        }
1818
1819        fn tool_call_done() -> WireInput {
1820            sse(&json!({
1821                "type": "response.output_item.done",
1822                "output_index": 0,
1823                "sequence_number": 2,
1824                "item": {
1825                    "type": "function_call",
1826                    "id": "fc_1",
1827                    "arguments": "{\"city\":\"Tokyo\"}",
1828                    "call_id": "call_1",
1829                    "name": "get_weather",
1830                    "status": "completed",
1831                },
1832            }))
1833        }
1834
1835        /// Synthetic twin of the recorded
1836        /// `openai/streaming_grammar/incomplete_mid_tool_call` cassette: a
1837        /// forced tool call cut by `max_output_tokens` mid-arguments. The wire
1838        /// restates the call on `response.output_item.done` with the
1839        /// arguments truncated mid-JSON and item status `incomplete`, then
1840        /// ends with a genuine `response.incomplete` terminal.
1841        pub fn incomplete_mid_tool_call_frames() -> Vec<WireInput> {
1842            vec![
1843                sse(&json!({
1844                    "type": "response.output_item.added",
1845                    "output_index": 0,
1846                    "sequence_number": 1,
1847                    "item": {
1848                        "type": "function_call",
1849                        "id": "fc_1",
1850                        "arguments": "",
1851                        "call_id": "call_1",
1852                        "name": "add",
1853                        "status": "in_progress",
1854                    },
1855                })),
1856                sse(&json!({
1857                    "type": "response.function_call_arguments.delta",
1858                    "item_id": "fc_1",
1859                    "output_index": 0,
1860                    "sequence_number": 2,
1861                    "delta": "{\"x",
1862                })),
1863                sse(&json!({
1864                    "type": "response.function_call_arguments.delta",
1865                    "item_id": "fc_1",
1866                    "output_index": 0,
1867                    "sequence_number": 3,
1868                    "delta": "\":48151",
1869                })),
1870                sse(&json!({
1871                    "type": "response.function_call_arguments.done",
1872                    "item_id": "fc_1",
1873                    "output_index": 0,
1874                    "sequence_number": 4,
1875                    "arguments": "{\"x\":48151",
1876                })),
1877                sse(&json!({
1878                    "type": "response.output_item.done",
1879                    "output_index": 0,
1880                    "sequence_number": 5,
1881                    "item": {
1882                        "type": "function_call",
1883                        "id": "fc_1",
1884                        "arguments": "{\"x\":48151",
1885                        "call_id": "call_1",
1886                        "name": "add",
1887                        "status": "incomplete",
1888                    },
1889                })),
1890                sse(&json!({
1891                    "type": "response.incomplete",
1892                    "sequence_number": 6,
1893                    "response": {
1894                        "id": "resp_1",
1895                        "object": "response",
1896                        "created_at": 0,
1897                        "status": "incomplete",
1898                        "incomplete_details": {"reason": "max_output_tokens"},
1899                        "model": "gpt-5.4",
1900                        "output": [{
1901                            "type": "function_call",
1902                            "id": "fc_1",
1903                            "arguments": "{\"x\":48151",
1904                            "call_id": "call_1",
1905                            "name": "add",
1906                            "status": "incomplete",
1907                        }],
1908                        "tools": [],
1909                        "usage": usage_json(),
1910                    },
1911                })),
1912            ]
1913        }
1914
1915        fn reasoning_done_item(
1916            id: &str,
1917            summary: &serde_json::Value,
1918            content: &serde_json::Value,
1919            encrypted: Option<&str>,
1920        ) -> WireInput {
1921            let mut item = json!({
1922                "type": "reasoning",
1923                "id": id,
1924                "summary": summary,
1925                "content": content,
1926                "status": "completed",
1927            });
1928            if let (Some(encrypted), Some(object)) = (encrypted, item.as_object_mut()) {
1929                object.insert("encrypted_content".to_string(), json!(encrypted));
1930            }
1931            sse(&json!({
1932                "type": "response.output_item.done",
1933                "output_index": 0,
1934                "sequence_number": 3,
1935                "item": item,
1936            }))
1937        }
1938
1939        /// The Responses-API fixture.
1940        pub fn fixture() -> ProviderWireFixture {
1941            ProviderWireFixture {
1942                driver: driver(),
1943                text_frames: vec![text_delta("hi")],
1944                expected_texts: vec!["hi"],
1945                tool_call_frames: vec![tool_call_done()],
1946                expected_tool_name: "get_weather",
1947                partial_tool_call_frames: Some(vec![
1948                    sse(&json!({
1949                        "type": "response.output_item.added",
1950                        "output_index": 0,
1951                        "sequence_number": 1,
1952                        "item": {
1953                            "type": "function_call",
1954                            "id": "fc_1",
1955                            "arguments": "",
1956                            "call_id": "call_1",
1957                            "name": "get_weather",
1958                            "status": "in_progress",
1959                        },
1960                    })),
1961                    sse(&json!({
1962                        "type": "response.function_call_arguments.delta",
1963                        "item_id": "fc_1",
1964                        "output_index": 0,
1965                        "sequence_number": 2,
1966                        "delta": "{\"cit",
1967                    })),
1968                ]),
1969                terminal_frames: vec![terminal(Some(&usage_json()), &json!([]))],
1970                expected_usage_total: 15,
1971                expected_finish_reason: Some(FinishReason::Stop),
1972                zero_usage_terminal_frames: Some(vec![terminal(None, &json!([]))]),
1973                bare_terminal_frames: None,
1974                malformed_frame: Some(sse_raw("{not json")),
1975                unknown_event_frame: Some(sse(&json!({
1976                    "type": "response.web_search_call.searching",
1977                    "output_index": 0,
1978                    "sequence_number": 4,
1979                    "item_id": "ws_1",
1980                }))),
1981                // The decoder reads only the fields it builds blocks from, and
1982                // reads those leniently, so no known event fails a reply (#2668).
1983                defective_known_frame: None,
1984                delta_less_prelude_frame: None,
1985                refusal: Some(RefusalFixture {
1986                    frames: vec![sse(&json!({
1987                        "type": "response.refusal.delta",
1988                        "content_index": 0,
1989                        "delta": "I cannot help with that.",
1990                        "item_id": "msg_1",
1991                        "output_index": 0,
1992                        "sequence_number": 1,
1993                    }))],
1994                    expected_text: "I cannot help with that.",
1995                }),
1996                interleaved_reasoning: None,
1997            }
1998        }
1999
2000        /// The buffered-body pipeline the ChatGPT backend uses: the SSE body
2001        /// is re-parsed after the fact and merged with the terminal response
2002        /// body, per content kind.
2003        ///
2004        /// Drives the *real* entry — `Model::call` on the
2005        /// ChatGPT wire whose HTTP double answers the `/responses` POST with
2006        /// the scripted SSE body — so the scenario exercises the buffered
2007        /// fold itself rather than a mirrored copy of it (#2258 review, F8
2008        /// drift risk).
2009        pub fn buffered_driver() -> BufferedBodyDriver {
2010            BufferedBodyDriver::new("chatgpt", |body| {
2011                Box::pin(async move {
2012                    let model = crate::driver::Model::new(
2013                        crate::providers::openai::OpenAIConfig::with_key(
2014                            &crate::providers::chatgpt::DIALECT,
2015                            "test-token",
2016                        )
2017                        .with_account_id("account-id")
2018                        .responses("gpt-5.4"),
2019                        crate::test_utils::RecordingHttpClient::new(body),
2020                    );
2021                    let request = CompletionRequest::new("hello");
2022                    let response = model.call(request).await?;
2023                    Ok(response.choice)
2024                })
2025            })
2026        }
2027
2028        fn message_output(text: &str) -> serde_json::Value {
2029            json!([{
2030                "type": "message",
2031                "id": "msg_1",
2032                "role": "assistant",
2033                "status": "completed",
2034                "content": [{"type": "output_text", "text": text, "annotations": []}],
2035            }])
2036        }
2037
2038        /// A terminal whose body carries text never seen as a delta.
2039        pub fn terminal_body_only_sse_body(text: &str) -> String {
2040            frame_text(&terminal(Some(&usage_json()), &message_output(text)))
2041        }
2042
2043        /// A streamed delta plus a terminal body restating the same text.
2044        pub fn terminal_body_and_delta_sse_body(text: &str) -> String {
2045            let frames = [
2046                text_delta(text),
2047                terminal(Some(&usage_json()), &message_output(text)),
2048            ];
2049            frames.iter().map(frame_text).collect()
2050        }
2051
2052        /// A streamed delta whose terminal body carries no output items — the
2053        /// gpt-5.x shape the buffered fallback exists for.
2054        pub fn delta_only_sse_body(text: &str) -> String {
2055            let frames = [text_delta(text), terminal(Some(&usage_json()), &json!([]))];
2056            frames.iter().map(frame_text).collect()
2057        }
2058
2059        /// The ChatGPT envelope-less replay shape (#2258 F3): a summary delta
2060        /// with NO envelope bookkeeping at all (repair injects
2061        /// `output_index: 0`, minting the `output-0` identity), then the
2062        /// item's envelope-full `output_item.done` restating the summary
2063        /// under its real `rs_*` id, then the terminal. The done item must
2064        /// adopt the minted per-slot identity and supersede the delta build.
2065        pub fn envelope_less_reasoning_supersede_sse_body() -> (String, &'static str) {
2066            let delta = json!({
2067                "type": "response.reasoning_summary_text.delta",
2068                "delta": "step 1",
2069            });
2070            let frames = [
2071                sse(&delta),
2072                reasoning_done_item(
2073                    "rs_1",
2074                    &json!([{"type": "summary_text", "text": "step 1"}]),
2075                    &json!([]),
2076                    None,
2077                ),
2078                terminal(Some(&usage_json()), &json!([])),
2079            ];
2080            (frames.iter().map(frame_text).collect(), "step 1")
2081        }
2082
2083        /// Summary deltas followed by their item's full `output_item.done`
2084        /// block, then the terminal. The deltas carry `item_id` on the wire;
2085        /// the full block restates the summary.
2086        pub fn reasoning_summary_supersede_frames() -> (Vec<WireInput>, &'static str) {
2087            let frames = vec![
2088                sse(&json!({
2089                    "type": "response.reasoning_summary_text.delta",
2090                    "item_id": "rs_1",
2091                    "output_index": 0,
2092                    "summary_index": 0,
2093                    "sequence_number": 1,
2094                    "delta": "step 1",
2095                })),
2096                reasoning_done_item(
2097                    "rs_1",
2098                    &json!([{"type": "summary_text", "text": "step 1"}]),
2099                    &json!([]),
2100                    None,
2101                ),
2102                terminal(Some(&usage_json()), &json!([])),
2103            ];
2104            (frames, "step 1")
2105        }
2106
2107        /// One reasoning item done-block carrying two summary parts, visible
2108        /// text, and encrypted content under a single item id.
2109        pub fn multi_part_reasoning_frames() -> (Vec<WireInput>, Vec<&'static str>) {
2110            let frames = vec![
2111                reasoning_done_item(
2112                    "rs_1",
2113                    &json!([
2114                        {"type": "summary_text", "text": "s1"},
2115                        {"type": "summary_text", "text": "s2"},
2116                    ]),
2117                    &json!([{"type": "reasoning_text", "text": "visible"}]),
2118                    Some("enc_blob"),
2119                ),
2120                terminal(Some(&usage_json()), &json!([])),
2121            ];
2122            (frames, vec!["s1", "s2", "visible", "enc_blob"])
2123        }
2124
2125        /// A reasoning delta, an interleaved tool call, then the reasoning
2126        /// item's completed block and the terminal.
2127        pub fn interleaved_reasoning_frames() -> (Vec<WireInput>, &'static str) {
2128            let frames = vec![
2129                sse(&json!({
2130                    "type": "response.reasoning_text.delta",
2131                    "item_id": "rs_2",
2132                    "output_index": 0,
2133                    "content_index": 0,
2134                    "sequence_number": 1,
2135                    "delta": "full ",
2136                })),
2137                // The call is the reply's next output item.
2138                sse(&json!({
2139                    "type": "response.output_item.done",
2140                    "output_index": 1,
2141                    "sequence_number": 2,
2142                    "item": {
2143                        "type": "function_call",
2144                        "id": "fc_1",
2145                        "arguments": "{\"city\":\"Tokyo\"}",
2146                        "call_id": "call_1",
2147                        "name": "get_weather",
2148                        "status": "completed",
2149                    },
2150                })),
2151                reasoning_done_item(
2152                    "rs_2",
2153                    &json!([]),
2154                    &json!([{"type": "reasoning_text", "text": "full reasoning"}]),
2155                    None,
2156                ),
2157                terminal(Some(&usage_json()), &json!([])),
2158            ];
2159            (frames, "full reasoning")
2160        }
2161    }
2162
2163    /// Gemini REST (`streamGenerateContent`) SSE wire.
2164    pub mod gemini_rest {
2165        use super::*;
2166
2167        fn driver() -> WireDriver {
2168            byte_driver("gemini", |transport| {
2169                crate::driver::Model::new(
2170                    crate::providers::gemini::GeminiConfig::new("test-key").completion(
2171                        crate::providers::gemini::completion::GEMINI_2_5_PRO_PREVIEW_06_05,
2172                    ),
2173                    transport,
2174                )
2175            })
2176        }
2177
2178        /// The Gemini REST fixture.
2179        pub fn fixture() -> ProviderWireFixture {
2180            ProviderWireFixture {
2181                driver: driver(),
2182                text_frames: vec![sse(&json!({
2183                    "candidates": [{"content": {"parts": [{"text": "hi"}], "role": "model"}}],
2184                    "responseId": "resp-1",
2185                    "modelVersion": "gemini-2.5-pro",
2186                }))],
2187                expected_texts: vec!["hi"],
2188                tool_call_frames: vec![sse(&json!({
2189                    "candidates": [{"content": {"parts": [{
2190                        "functionCall": {"name": "get_weather", "args": {"city": "Tokyo"}},
2191                    }], "role": "model"}}],
2192                    "responseId": "resp-1",
2193                    "modelVersion": "gemini-2.5-pro",
2194                }))],
2195                expected_tool_name: "get_weather",
2196                // Gemini delivers tool calls whole; arguments never stream.
2197                partial_tool_call_frames: None,
2198                terminal_frames: vec![sse(&json!({
2199                    "candidates": [{
2200                        "content": {"parts": [], "role": "model"},
2201                        "finishReason": "STOP",
2202                    }],
2203                    "usageMetadata": {
2204                        "promptTokenCount": 5,
2205                        "candidatesTokenCount": 2,
2206                        "totalTokenCount": 7,
2207                    },
2208                    "responseId": "resp-1",
2209                    "modelVersion": "gemini-2.5-pro",
2210                }))],
2211                expected_usage_total: 7,
2212                expected_finish_reason: Some(FinishReason::Stop),
2213                zero_usage_terminal_frames: Some(vec![sse(&json!({
2214                    "candidates": [{
2215                        "content": {"parts": [], "role": "model"},
2216                        "finishReason": "STOP",
2217                    }],
2218                    "responseId": "resp-1",
2219                    "modelVersion": "gemini-2.5-pro",
2220                }))]),
2221                bare_terminal_frames: None,
2222                malformed_frame: Some(sse_raw("{not json")),
2223                // The wire has no event tag; valid JSON carrying neither
2224                // `candidates` nor `usageMetadata` is unrecognizable and must
2225                // be warn-skipped, not silently decoded as an empty chunk.
2226                unknown_event_frame: Some(sse_raw(r#"{"noise":true}"#)),
2227                defective_known_frame: Some(sse_raw(r#"{"candidates": 42}"#)),
2228                delta_less_prelude_frame: None,
2229                refusal: None,
2230                interleaved_reasoning: Some(interleaved_thought_fixture()),
2231            }
2232        }
2233
2234        fn chunk(parts: &serde_json::Value) -> WireInput {
2235            sse(&json!({
2236                "candidates": [{"content": {"parts": parts, "role": "model"}}],
2237                "responseId": "resp-1",
2238                "modelVersion": "gemini-2.5-pro",
2239            }))
2240        }
2241
2242        fn terminal_frame() -> WireInput {
2243            sse(&json!({
2244                "candidates": [{
2245                    "content": {"parts": [], "role": "model"},
2246                    "finishReason": "STOP",
2247                }],
2248                "usageMetadata": {
2249                    "promptTokenCount": 5,
2250                    "candidatesTokenCount": 2,
2251                    "totalTokenCount": 7,
2252                },
2253                "responseId": "resp-1",
2254                "modelVersion": "gemini-2.5-pro",
2255            }))
2256        }
2257
2258        /// Thought delta, interleaved tool call, thought delta, terminal —
2259        /// the constant-id (`reasoning-0`) interleaving shape.
2260        fn interleaved_thought_fixture() -> InterleavedReasoningFixture {
2261            InterleavedReasoningFixture {
2262                frames: vec![
2263                    chunk(&json!([{"text": "before tool", "thought": true}])),
2264                    chunk(&json!([{
2265                        "functionCall": {"name": "get_weather", "args": {"city": "Tokyo"}},
2266                    }])),
2267                    chunk(&json!([{"text": "after tool", "thought": true}])),
2268                    terminal_frame(),
2269                ],
2270                first_reasoning: "before tool",
2271                tool_name: "get_weather",
2272                second_reasoning: "after tool",
2273            }
2274        }
2275
2276        /// Thought delta, interleaved tool call, then a signed full thought
2277        /// chunk carrying non-empty text — the F1 erasure shape.
2278        pub fn interleaved_signed_thought_frames()
2279        -> (Vec<WireInput>, &'static str, &'static str, &'static str) {
2280            let frames = vec![
2281                chunk(&json!([{"text": "before tool", "thought": true}])),
2282                chunk(&json!([{
2283                    "functionCall": {"name": "get_weather", "args": {"city": "Tokyo"}},
2284                }])),
2285                chunk(&json!([{
2286                    "text": "signed conclusion",
2287                    "thought": true,
2288                    "thoughtSignature": "sig-1",
2289                }])),
2290                terminal_frame(),
2291            ];
2292            (frames, "before tool", "get_weather", "signed conclusion")
2293        }
2294    }
2295
2296    /// Gemini Interactions SSE wire (`event_type`-tagged events).
2297    pub mod interactions {
2298        use super::*;
2299
2300        fn driver() -> WireDriver {
2301            byte_driver("gemini", |transport| {
2302                crate::driver::Model::new(
2303                    crate::providers::gemini::GeminiConfig::new("test-key")
2304                        .interactions("gemini-2.5-pro"),
2305                    transport,
2306                )
2307            })
2308        }
2309
2310        fn completed(usage: Option<serde_json::Value>) -> WireInput {
2311            let mut interaction = json!({
2312                "id": "int-1",
2313                "model": "gemini-2.5-pro",
2314                "status": "completed",
2315            });
2316            if let (Some(usage), Some(object)) = (usage, interaction.as_object_mut()) {
2317                object.insert("usage".to_string(), usage);
2318            }
2319            sse(&json!({
2320                "event_type": "interaction.completed",
2321                "interaction": interaction,
2322            }))
2323        }
2324
2325        /// The Interactions fixture.
2326        pub fn fixture() -> ProviderWireFixture {
2327            ProviderWireFixture {
2328                driver: driver(),
2329                text_frames: vec![sse(&json!({
2330                    "event_type": "step.delta",
2331                    "index": 0,
2332                    "delta": {"type": "text", "text": "hi"},
2333                }))],
2334                expected_texts: vec!["hi"],
2335                tool_call_frames: vec![sse(&json!({
2336                    "event_type": "step.delta",
2337                    "index": 0,
2338                    "delta": {
2339                        "type": "function_call",
2340                        "name": "get_weather",
2341                        "arguments": {"city": "Tokyo"},
2342                        "id": "call-1",
2343                    },
2344                }))],
2345                expected_tool_name: "get_weather",
2346                // The Interactions wire delivers function calls whole;
2347                // arguments never stream.
2348                partial_tool_call_frames: None,
2349                terminal_frames: vec![completed(Some(json!({
2350                    "total_input_tokens": 5,
2351                    "total_output_tokens": 2,
2352                    "total_tokens": 7,
2353                })))],
2354                expected_usage_total: 7,
2355                expected_finish_reason: Some(FinishReason::Stop),
2356                zero_usage_terminal_frames: Some(vec![completed(None)]),
2357                bare_terminal_frames: None,
2358                malformed_frame: Some(sse_raw("{not json")),
2359                unknown_event_frame: Some(sse(&json!({
2360                    "event_type": "future.event",
2361                    "index": 0,
2362                }))),
2363                // A known tag (`step.delta`) whose delta, the content of its
2364                // block, is not an object fails the reply, never skipped.
2365                defective_known_frame: Some(sse_raw(
2366                    r#"{"event_type":"step.delta","index":0,"delta":42}"#,
2367                )),
2368                delta_less_prelude_frame: None,
2369                refusal: None,
2370                interleaved_reasoning: Some(interleaved_thought_fixture()),
2371            }
2372        }
2373
2374        /// A thought step, a function-call step, a second thought step,
2375        /// terminal: each step is its own block on the Interactions wire.
2376        fn interleaved_thought_fixture() -> InterleavedReasoningFixture {
2377            let frames = vec![
2378                sse(&json!({
2379                    "event_type": "step.delta",
2380                    "index": 0,
2381                    "delta": {
2382                        "type": "thought_summary",
2383                        "content": {"type": "text", "text": "before tool"},
2384                    },
2385                })),
2386                sse(&json!({
2387                    "event_type": "step.delta",
2388                    "index": 1,
2389                    "delta": {
2390                        "type": "function_call",
2391                        "name": "get_weather",
2392                        "arguments": {"city": "Tokyo"},
2393                        "id": "call-1",
2394                    },
2395                })),
2396                sse(&json!({
2397                    "event_type": "step.delta",
2398                    "index": 2,
2399                    "delta": {
2400                        "type": "thought_summary",
2401                        "content": {"type": "text", "text": "after tool"},
2402                    },
2403                })),
2404                completed(Some(json!({
2405                    "total_input_tokens": 5,
2406                    "total_output_tokens": 2,
2407                    "total_tokens": 7,
2408                }))),
2409            ];
2410            InterleavedReasoningFixture {
2411                frames,
2412                first_reasoning: "before tool",
2413                tool_name: "get_weather",
2414                second_reasoning: "after tool",
2415            }
2416        }
2417    }
2418
2419    /// Anthropic Messages SSE wire (`type`-tagged events, index-as-id blocks).
2420    pub mod anthropic {
2421        use super::*;
2422
2423        fn driver() -> WireDriver {
2424            byte_driver("anthropic", |transport| {
2425                crate::driver::Model::new(
2426                    crate::providers::anthropic::wire::AnthropicConfig::new("test-key")
2427                        .completion(crate::providers::anthropic::completion::CLAUDE_SONNET_4_6),
2428                    transport,
2429                )
2430            })
2431        }
2432
2433        fn message_start() -> WireInput {
2434            sse(&json!({
2435                "type": "message_start",
2436                "message": {
2437                    "id": "msg_1",
2438                    "role": "assistant",
2439                    "content": [],
2440                    "model": "claude-sonnet-4-6",
2441                    "stop_reason": null,
2442                    "stop_sequence": null,
2443                    "usage": {"input_tokens": 5, "output_tokens": 0},
2444                },
2445            }))
2446        }
2447
2448        /// The Anthropic fixture.
2449        pub fn fixture() -> ProviderWireFixture {
2450            ProviderWireFixture {
2451                driver: driver(),
2452                text_frames: vec![
2453                    message_start(),
2454                    sse(&json!({
2455                        "type": "content_block_start",
2456                        "index": 0,
2457                        "content_block": {"type": "text", "text": ""},
2458                    })),
2459                    sse(&json!({
2460                        "type": "content_block_delta",
2461                        "index": 0,
2462                        "delta": {"type": "text_delta", "text": "hi"},
2463                    })),
2464                ],
2465                expected_texts: vec!["hi"],
2466                tool_call_frames: vec![
2467                    sse(&json!({
2468                        "type": "content_block_start",
2469                        "index": 0,
2470                        "content_block": {
2471                            "type": "tool_use",
2472                            "id": "toolu_1",
2473                            "name": "get_weather",
2474                            "input": {},
2475                        },
2476                    })),
2477                    sse(&json!({
2478                        "type": "content_block_delta",
2479                        "index": 0,
2480                        "delta": {"type": "input_json_delta", "partial_json": "{\"city\":\"Tokyo\"}"},
2481                    })),
2482                    // `content_block_stop` completes the call; the stream
2483                    // terminal (`message_delta`) is deliberately absent.
2484                    sse(&json!({"type": "content_block_stop", "index": 0})),
2485                ],
2486                expected_tool_name: "get_weather",
2487                partial_tool_call_frames: Some(vec![
2488                    sse(&json!({
2489                        "type": "content_block_start",
2490                        "index": 0,
2491                        "content_block": {
2492                            "type": "tool_use",
2493                            "id": "toolu_1",
2494                            "name": "get_weather",
2495                            "input": {},
2496                        },
2497                    })),
2498                    sse(&json!({
2499                        "type": "content_block_delta",
2500                        "index": 0,
2501                        "delta": {"type": "input_json_delta", "partial_json": "{\"cit"},
2502                    })),
2503                ]),
2504                terminal_frames: vec![sse(&json!({
2505                    "type": "message_delta",
2506                    "delta": {"stop_reason": "end_turn", "stop_sequence": null},
2507                    "usage": {"output_tokens": 4},
2508                }))],
2509                // input 5 (from message_start) + output 4.
2510                expected_usage_total: 9,
2511                expected_finish_reason: Some(FinishReason::Stop),
2512                // The Anthropic terminal always carries `usage`; there is no
2513                // usage-less genuine terminal to spell on this wire.
2514                zero_usage_terminal_frames: None,
2515                // `message_stop` carries no data of its own and must not
2516                // fabricate a terminal record.
2517                bare_terminal_frames: Some(vec![sse(&json!({"type": "message_stop"}))]),
2518                malformed_frame: Some(sse_raw("{not json")),
2519                unknown_event_frame: Some(sse(&json!({
2520                    "type": "content_block_heartbeat",
2521                    "index": 0,
2522                }))),
2523                // A known tag (`content_block_delta`) with a schema-defective
2524                // payload must classify `Corrupt`, never `Unknown`.
2525                defective_known_frame: Some(sse_raw(
2526                    r#"{"type":"content_block_delta","index":0,"delta":42}"#,
2527                )),
2528                delta_less_prelude_frame: None,
2529                refusal: None,
2530                interleaved_reasoning: None,
2531            }
2532        }
2533    }
2534}