Skip to main content

meerkat_contracts/wire/
live.rs

1//! `live/*` RPC wire contracts.
2//!
3//! Typed parameter and result shapes for the `live/open`, `live/status`,
4//! `live/close`, `live/send_input`, `live/commit_input`, `live/interrupt`,
5//! and `live/truncate` JSON-RPC methods. Promoted from
6//! `meerkat-rpc/src/handlers/live.rs` (I52) so SDKs and the protocol catalog
7//! can reference them as typed contracts rather than free-form `basic`
8//! entries.
9//!
10//! Result shapes that carry semantic adapter state (`transport`,
11//! `capabilities`, `continuity`, `status`) reference the canonical types in
12//! `meerkat_core::live_adapter`. The RPC catalog publishes the types under
13//! the `LiveOpenResult` / `LiveStatusResult` titles so SDK codegen sees a
14//! single source of truth.
15
16use serde::{Deserialize, Serialize};
17
18use meerkat_core::Provider;
19use meerkat_core::live_adapter::{
20    LiveAdapterErrorCode, LiveAdapterObservation, LiveAdapterStatus, LiveChannelCapabilities,
21    LiveConfigRejectionReason, LiveContinuityMode, LiveDegradationReason, LiveResponseModality,
22    LiveTransportBootstrap,
23};
24use meerkat_core::realtime_transcript::RealtimeTranscriptEvent;
25
26use crate::wire::realtime::RealtimeTurningMode;
27use crate::wire::session::WireStopReason;
28
29/// Wire-safe projection of [`meerkat_core::Provider`].
30///
31/// `WireProvider` pins each provider's canonical wire name with an explicit
32/// `#[serde(rename)]` (`"openai"`, `"anthropic"`, `"gemini"`, …). The core
33/// `Provider` enum now agrees (its `OpenAI` variant carries an explicit
34/// `#[serde(rename = "openai")]` so `rename_all = "snake_case"` can no longer
35/// mangle it into `"open_a_i"`); `WireProvider` exists as the dedicated wire
36/// mirror so SDK consumers get a stable, explicitly-named contract plus the
37/// fail-loud `Unknown` sentinel below, per the wire-mirror dogma used
38/// throughout this module.
39#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[non_exhaustive]
42pub enum WireProvider {
43    #[serde(rename = "anthropic")]
44    Anthropic,
45    #[serde(rename = "openai")]
46    OpenAi,
47    #[serde(rename = "gemini")]
48    Gemini,
49    #[serde(rename = "self_hosted")]
50    SelfHosted,
51    #[serde(rename = "other")]
52    Other,
53    /// Fail-loud sentinel for future core variants. When a new
54    /// `Provider` variant is added, add an explicit arm in the `From`
55    /// impl rather than letting it fall through to `Unknown`.
56    ///
57    /// Unlike other wire-mirror `Unknown` variants, this is a unit
58    /// variant because `WireProvider` serializes as a flat string value
59    /// (not an internally-tagged object). The debug payload from the
60    /// source variant is captured in the `WireConversionError::Provider`
61    /// error on the reverse path (and logged server-side in the forward
62    /// `From<Provider>` impl via `debug_assert!`).
63    #[serde(rename = "unknown")]
64    Unknown,
65}
66
67impl From<Provider> for WireProvider {
68    fn from(value: Provider) -> Self {
69        match value {
70            Provider::Anthropic => Self::Anthropic,
71            Provider::OpenAI => Self::OpenAi,
72            Provider::Gemini => Self::Gemini,
73            Provider::SelfHosted => Self::SelfHosted,
74            Provider::Other => Self::Other,
75            // Core `Provider` is not `#[non_exhaustive]` today, but the
76            // wildcard arm future-proofs the wire boundary.
77            #[allow(unreachable_patterns)]
78            other => {
79                debug_assert!(
80                    false,
81                    "WireProvider::from saw an unmapped Provider variant: {other:?}; \
82                     add an explicit arm in meerkat-contracts/src/wire/live.rs."
83                );
84                Self::Unknown
85            }
86        }
87    }
88}
89
90impl TryFrom<WireProvider> for Provider {
91    type Error = WireConversionError;
92
93    fn try_from(value: WireProvider) -> Result<Self, Self::Error> {
94        match value {
95            WireProvider::Anthropic => Ok(Self::Anthropic),
96            WireProvider::OpenAi => Ok(Self::OpenAI),
97            WireProvider::Gemini => Ok(Self::Gemini),
98            WireProvider::SelfHosted => Ok(Self::SelfHosted),
99            WireProvider::Other => Ok(Self::Other),
100            WireProvider::Unknown => Err(WireConversionError::Provider {
101                debug: "WireProvider::Unknown".to_string(),
102            }),
103        }
104    }
105}
106
107/// Request payload for `live/open`.
108///
109/// R3-1 (P1): `turning_mode` lets callers pick between the provider-managed
110/// (server-VAD) flow and the explicit-commit flow. The G9 typed text-only
111/// `live/commit_input { response_modality: text }` path requires
112/// `ExplicitCommit` because the OpenAI realtime API rejects
113/// `input_audio_buffer.commit` unless the session was opened with explicit
114/// commit semantics (server VAD owns commits otherwise). Pre-R3-1 the
115/// handler hard-coded `ProviderManaged`, which made the typed text-only
116/// path unreachable on a public channel — calling it killed the channel
117/// instead of producing sideband text.
118///
119/// Default (`None`) preserves the prior wire shape: callers that omit the
120/// field get `ProviderManaged`, matching the legacy behavior.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct LiveOpenParams {
124    pub session_id: String,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub turning_mode: Option<RealtimeTurningMode>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub transport: Option<LiveOpenTransport>,
129}
130
131/// Transport requested by `live/open`.
132///
133/// Missing means "server default": prefer WebSocket when configured, otherwise
134/// WebRTC when that is the only configured live transport. This preserves the
135/// pre-WebRTC `live/open` shape while keeping the new branch typed.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138#[serde(rename_all = "snake_case")]
139#[non_exhaustive]
140pub enum LiveOpenTransport {
141    Websocket,
142    Webrtc,
143}
144
145/// Response payload for `live/open`.
146///
147/// `capabilities`, `continuity`, and `transport` are typed wire-side mirrors
148/// of the core `LiveChannelCapabilities` / `LiveContinuityMode` /
149/// `LiveTransportBootstrap` so SDK codegen sees the real shape (typed
150/// booleans, internally-tagged variant payloads, discriminated transport
151/// union) instead of an opaque JSON blob. CC5/CC6 (PR #650 verifier
152/// follow-up); G8 (P2): `transport` typed-mirror.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct LiveOpenResult {
156    pub channel_id: String,
157    pub transport: WireLiveTransportBootstrap,
158    pub capabilities: WireLiveChannelCapabilities,
159    pub continuity: WireLiveContinuityMode,
160}
161
162/// Wire projection of [`meerkat_core::live_adapter::LiveTransportBootstrap`].
163///
164/// Internally-tagged on `transport` (snake_case) — matches the core enum's
165/// serde shape exactly so the wire payload is byte-identical. G8 (P2):
166/// closes the typed-surface gap that left `transport: unknown` in TS and
167/// `Any` in Python at the SDK boundary.
168///
169/// The core enum is `#[non_exhaustive]`; new transports (e.g. WebRTC
170/// reintroduction per Round-4 T4) appear here as additional typed variants
171/// rather than as a free-form JSON blob.
172#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174#[serde(tag = "transport", rename_all = "snake_case")]
175#[non_exhaustive]
176pub enum WireLiveTransportBootstrap {
177    /// WebSocket bootstrap: client opens a WS connection to `url` and
178    /// authenticates with `token`. Mirrors
179    /// [`meerkat_core::live_adapter::LiveTransportBootstrap::Websocket`].
180    Websocket { url: String, token: String },
181    /// WebRTC bootstrap: client calls `answer_method` with
182    /// [`LiveWebrtcAnswerParams`] using this single-use token. `http_url`
183    /// is optional convenience signaling; JSON-RPC is canonical.
184    Webrtc {
185        token: String,
186        answer_method: String,
187        #[serde(default, skip_serializing_if = "Option::is_none")]
188        http_url: Option<String>,
189    },
190    /// R3-6 (P2): explicit fail-loud variant for unknown core variants.
191    ///
192    /// The core [`LiveTransportBootstrap`] enum is `#[non_exhaustive]`. When
193    /// a future variant (e.g. WebRTC reintroduction per Round-4 T4) lands
194    /// without an explicit arm in the wire `From` impl, the conversion
195    /// surfaces as `Unknown { debug }` rather than silently coercing into
196    /// `Websocket { url: "", token: "" }`. SDK consumers route on the
197    /// `transport: "unknown"` discriminator and treat it as an unsupported
198    /// transport — never as a usable websocket. The `debug` payload carries
199    /// the `{:?}` projection of the source variant so server logs name the
200    /// real shape that needs to be promoted.
201    ///
202    /// **When a new core variant is added, add an explicit arm in the
203    /// forward `From` impl above this variant — `Unknown` is the floor, not
204    /// the destination.**
205    Unknown { debug: String },
206}
207
208impl From<LiveTransportBootstrap> for WireLiveTransportBootstrap {
209    fn from(value: LiveTransportBootstrap) -> Self {
210        match value {
211            LiveTransportBootstrap::Websocket { url, token } => Self::Websocket { url, token },
212            LiveTransportBootstrap::Webrtc {
213                token,
214                answer_method,
215                http_url,
216            } => Self::Webrtc {
217                token,
218                answer_method,
219                http_url,
220            },
221            // Core enum is `#[non_exhaustive]`. R3-6 (P2): surface unknown
222            // variants explicitly via `Unknown { debug }` rather than
223            // silently coercing to a bogus `Websocket { url: "", token: "" }`.
224            // The `debug` payload preserves the source variant name for
225            // server-side observability. When a new core variant lands,
226            // add an explicit arm above this comment.
227            other => {
228                debug_assert!(
229                    false,
230                    "WireLiveTransportBootstrap::from saw an unmapped \
231                     LiveTransportBootstrap variant; add an explicit arm in \
232                     meerkat-contracts/src/wire/live.rs."
233                );
234                Self::Unknown {
235                    debug: format!("{other:?}"),
236                }
237            }
238        }
239    }
240}
241
242// Re-export from the shared wire error module for backwards compatibility.
243// Callers that previously imported `crate::wire::live::WireConversionError`
244// continue to compile; new code should import from `crate::wire::WireConversionError`.
245pub use crate::wire::error::WireConversionError;
246
247impl TryFrom<WireLiveTransportBootstrap> for LiveTransportBootstrap {
248    type Error = WireConversionError;
249
250    fn try_from(value: WireLiveTransportBootstrap) -> Result<Self, Self::Error> {
251        // Wire enum is owned by this crate so even with `#[non_exhaustive]`
252        // (which only constrains matches outside the defining crate) the
253        // compiler enforces exhaustive coverage here. Adding a new wire
254        // variant therefore forces a new arm in this match — no silent
255        // fallthrough.
256        match value {
257            WireLiveTransportBootstrap::Websocket { url, token } => {
258                Ok(Self::Websocket { url, token })
259            }
260            WireLiveTransportBootstrap::Webrtc {
261                token,
262                answer_method,
263                http_url,
264            } => Ok(Self::Webrtc {
265                token,
266                answer_method,
267                http_url,
268            }),
269            WireLiveTransportBootstrap::Unknown { debug } => {
270                Err(WireConversionError::Transport { debug })
271            }
272        }
273    }
274}
275
276/// Request payload for `live/webrtc/answer`.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
279pub struct LiveWebrtcAnswerParams {
280    pub channel_id: String,
281    pub token: String,
282    pub offer_sdp: String,
283}
284
285/// Response payload for `live/webrtc/answer`.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
288pub struct LiveWebrtcAnswerResult {
289    pub answer_sdp: String,
290}
291
292/// Wire projection of [`meerkat_core::live_adapter::LiveChannelCapabilities`].
293///
294/// Typed-boolean matrix advertised when a live channel opens. SDK consumers
295/// (Python `client.py`, TypeScript `client.ts`, Web SDK) get typed access to
296/// `image_in` / `video_in` / `transcript_supported` etc. without needing to
297/// hand-decode an opaque JSON object. CC5: closes the typed-surface gap that
298/// hid T8's "anticipate `gpt-realtime-2`" goal at the SDK boundary.
299///
300/// Field shape mirrors the core type 1:1 — adding a new capability requires
301/// extending both the core type and this mirror; the `From` impls below
302/// fail-closed if a field is dropped (compile error: missing field). New
303/// modalities appear here as additional typed booleans, never as
304/// stringly-typed lists or provider-specific enums.
305#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307pub struct WireLiveChannelCapabilities {
308    /// Adapter accepts audio chunks via `live/send_input`.
309    pub audio_in: bool,
310    /// Adapter emits audio chunks via `AssistantAudioChunk` observations.
311    pub audio_out: bool,
312    /// Adapter accepts text chunks via `live/send_input`.
313    pub text_in: bool,
314    /// Adapter emits display text via `AssistantTextDelta` observations.
315    pub text_out: bool,
316    /// Adapter accepts image input via `live/send_input` (e.g. future
317    /// `gpt-realtime-2` image support). Today: `false` for OpenAI realtime.
318    pub image_in: bool,
319    /// Adapter accepts video-frame input via `live/send_input` (e.g.
320    /// Gemini Live). Today: `false` for OpenAI realtime.
321    pub video_in: bool,
322    /// Adapter emits spoken-audio transcripts via
323    /// `AssistantTranscriptDelta` / `AssistantTranscriptFinal`.
324    pub transcript_supported: bool,
325    /// Adapter supports user-initiated barge-in (turn truncation) via
326    /// `live/interrupt` and the `TurnInterrupted` observation.
327    pub barge_in_supported: bool,
328    /// Adapter can resume a prior provider-side session by id (transcript-
329    /// only resume does not count). `false` until a provider exposes a real
330    /// continuation handle.
331    pub provider_native_resume: bool,
332}
333
334impl From<LiveChannelCapabilities> for WireLiveChannelCapabilities {
335    fn from(value: LiveChannelCapabilities) -> Self {
336        let LiveChannelCapabilities {
337            audio_in,
338            audio_out,
339            text_in,
340            text_out,
341            image_in,
342            video_in,
343            transcript_supported,
344            barge_in_supported,
345            provider_native_resume,
346        } = value;
347        Self {
348            audio_in,
349            audio_out,
350            text_in,
351            text_out,
352            image_in,
353            video_in,
354            transcript_supported,
355            barge_in_supported,
356            provider_native_resume,
357        }
358    }
359}
360
361impl From<WireLiveChannelCapabilities> for LiveChannelCapabilities {
362    fn from(value: WireLiveChannelCapabilities) -> Self {
363        let WireLiveChannelCapabilities {
364            audio_in,
365            audio_out,
366            text_in,
367            text_out,
368            image_in,
369            video_in,
370            transcript_supported,
371            barge_in_supported,
372            provider_native_resume,
373        } = value;
374        Self {
375            audio_in,
376            audio_out,
377            text_in,
378            text_out,
379            image_in,
380            video_in,
381            transcript_supported,
382            barge_in_supported,
383            provider_native_resume,
384        }
385    }
386}
387
388/// Wire projection of [`meerkat_core::live_adapter::LiveContinuityMode`].
389///
390/// Internally-tagged on `mode` (snake_case) — matches the core enum's serde
391/// shape exactly so the wire payload is byte-identical. CC6: closes the
392/// typed-surface gap. SDK consumers get a discriminated union (TS) or
393/// tagged-variant (Python) instead of a raw JSON blob, and the
394/// `ProviderNativeResume { provider_session_id }` payload field is visible
395/// to schema codegen.
396///
397/// **Breaking-change note (pre-1.0 dogma, no shims):** T12 already moved the
398/// core enum from a bare-string serde shape (`"transcript_only"`) to the
399/// internally-tagged form (`{"mode":"transcript_only"}`). This wire mirror
400/// makes that shape change visible to schema codegen and SDK clients.
401#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
402#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
403#[serde(tag = "mode", rename_all = "snake_case")]
404#[non_exhaustive]
405pub enum WireLiveContinuityMode {
406    /// Brand-new live channel; no prior session history.
407    Fresh,
408    /// History seeded from canonical transcript only. Honest about loss of
409    /// AEC / playback-cursor / pronunciation / provider-native state.
410    TranscriptOnly,
411    /// History seeded but with known gaps.
412    Degraded,
413    /// Provider surfaced a session id we can attach back to. Only mode that
414    /// preserves provider-native state across reconnects. No provider
415    /// Meerkat ships today returns a usable resume id.
416    ProviderNativeResume { provider_session_id: String },
417    /// R5-3 (P3): explicit fail-loud variant for unknown core variants.
418    ///
419    /// The core [`LiveContinuityMode`] enum is `#[non_exhaustive]`. When a
420    /// future variant lands without an explicit arm in the wire `From`
421    /// impl, the conversion surfaces as `Unknown { debug }` rather than
422    /// silently coercing into `Fresh` — a "plausible lie" that would tell
423    /// SDK consumers a brand-new live channel exists when in reality the
424    /// server emitted a continuity mode the client does not yet
425    /// understand. SDK consumers route on the `mode: "unknown"`
426    /// discriminator and treat it as unrecognized — never as `Fresh`.
427    ///
428    /// **When a new core variant is added, add an explicit arm in the
429    /// forward `From` impl above this variant — `Unknown` is the floor,
430    /// not the destination.**
431    Unknown { debug: String },
432}
433
434impl From<LiveContinuityMode> for WireLiveContinuityMode {
435    fn from(value: LiveContinuityMode) -> Self {
436        match value {
437            LiveContinuityMode::Fresh => Self::Fresh,
438            LiveContinuityMode::TranscriptOnly => Self::TranscriptOnly,
439            LiveContinuityMode::Degraded => Self::Degraded,
440            LiveContinuityMode::ProviderNativeResume {
441                provider_session_id,
442            } => Self::ProviderNativeResume {
443                provider_session_id,
444            },
445            // Core enum is `#[non_exhaustive]`. R5-3 (P3): surface unknown
446            // variants explicitly via `Unknown { debug }` rather than
447            // silently coercing to `Fresh` (the previous fail-open default
448            // — a plausible lie that would mask a real continuity-mode
449            // change as a brand-new channel). When a new core variant
450            // lands, add an explicit arm above this comment.
451            other => {
452                debug_assert!(
453                    false,
454                    "WireLiveContinuityMode::from saw an unmapped \
455                     LiveContinuityMode variant; add an explicit arm in \
456                     meerkat-contracts/src/wire/live.rs."
457                );
458                Self::Unknown {
459                    debug: format!("{other:?}"),
460                }
461            }
462        }
463    }
464}
465
466impl TryFrom<WireLiveContinuityMode> for LiveContinuityMode {
467    type Error = WireConversionError;
468
469    fn try_from(value: WireLiveContinuityMode) -> Result<Self, Self::Error> {
470        // No wildcard arm: `WireLiveContinuityMode` is owned by this crate
471        // so even with `#[non_exhaustive]` (which only constrains matches
472        // outside the defining crate) the compiler enforces exhaustive
473        // coverage here. Adding a new wire variant therefore forces a new
474        // arm in this match — no silent fallthrough.
475        match value {
476            WireLiveContinuityMode::Fresh => Ok(Self::Fresh),
477            WireLiveContinuityMode::TranscriptOnly => Ok(Self::TranscriptOnly),
478            WireLiveContinuityMode::Degraded => Ok(Self::Degraded),
479            WireLiveContinuityMode::ProviderNativeResume {
480                provider_session_id,
481            } => Ok(Self::ProviderNativeResume {
482                provider_session_id,
483            }),
484            WireLiveContinuityMode::Unknown { debug } => {
485                Err(WireConversionError::Continuity { debug })
486            }
487        }
488    }
489}
490
491/// Request payload for `live/status`, `live/close`, and `live/interrupt`.
492/// They all take the same `{channel_id}` shape; this struct is the typed
493/// name for it.
494///
495/// `live/commit_input` no longer uses this shape — it carries an optional
496/// `response_modality` override (G9) so callers can request a text-only
497/// response on an audio-first channel without flipping the channel
498/// modality. See [`LiveCommitInputParams`].
499#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
500#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
501pub struct LiveChannelParams {
502    pub channel_id: String,
503}
504
505/// Wire projection of [`meerkat_core::live_adapter::LiveResponseModality`].
506///
507/// Internally-tagged on `modality` (snake_case) — matches the core enum's
508/// serde shape exactly so the wire payload is byte-identical. G9: closes
509/// the typed-surface gap so SDK clients can pick `audio` vs `text` on a
510/// per-response basis without round-tripping through a free-form string.
511///
512/// The core enum is `#[non_exhaustive]`; new modalities (e.g. structured
513/// output, image) appear here as additional typed variants.
514///
515/// R5-3 (P3): no longer `Copy` because `Unknown { debug: String }` carries
516/// an owned payload. The enum is small and conversions across the boundary
517/// move-or-clone, so the loss of `Copy` is not material at call sites.
518#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
519#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
520#[serde(tag = "modality", rename_all = "snake_case")]
521#[non_exhaustive]
522pub enum WireLiveResponseModality {
523    /// Spoken-audio plus the audio-derived transcript.
524    Audio,
525    /// Display text only — no audio output, no transcript.
526    Text,
527    /// R5-3 (P3): explicit fail-loud variant for unknown core variants.
528    ///
529    /// The core [`LiveResponseModality`] enum is `#[non_exhaustive]`. When
530    /// a future modality (e.g. structured output, image) lands without an
531    /// explicit arm in the wire `From` impl, the conversion surfaces as
532    /// `Unknown { debug }` rather than silently coercing to `Audio` — a
533    /// "plausible lie" that would route a future text/structured response
534    /// through the audio playback path and drop content. SDK consumers
535    /// route on the `modality: "unknown"` discriminator and treat it as an
536    /// unsupported modality — never as `Audio`.
537    ///
538    /// **When a new core variant is added, add an explicit arm in the
539    /// forward `From` impl above this variant — `Unknown` is the floor,
540    /// not the destination.**
541    Unknown { debug: String },
542}
543
544impl From<LiveResponseModality> for WireLiveResponseModality {
545    fn from(value: LiveResponseModality) -> Self {
546        match value {
547            LiveResponseModality::Audio => Self::Audio,
548            LiveResponseModality::Text => Self::Text,
549            // Core enum is `#[non_exhaustive]`. R5-3 (P3): surface unknown
550            // variants explicitly via `Unknown { debug }` rather than
551            // silently coercing to `Audio` (the previous fail-open default
552            // — a plausible lie that would route a future text/structured
553            // response through the audio playback path). When a new core
554            // variant lands, add an explicit arm above this comment.
555            other => {
556                debug_assert!(
557                    false,
558                    "WireLiveResponseModality::from saw an unmapped \
559                     LiveResponseModality variant; add an explicit arm in \
560                     meerkat-contracts/src/wire/live.rs."
561                );
562                Self::Unknown {
563                    debug: format!("{other:?}"),
564                }
565            }
566        }
567    }
568}
569
570impl TryFrom<WireLiveResponseModality> for LiveResponseModality {
571    type Error = WireConversionError;
572
573    fn try_from(value: WireLiveResponseModality) -> Result<Self, Self::Error> {
574        // No wildcard arm: `WireLiveResponseModality` is owned by this
575        // crate so even with `#[non_exhaustive]` the compiler enforces
576        // exhaustive coverage here.
577        match value {
578            WireLiveResponseModality::Audio => Ok(Self::Audio),
579            WireLiveResponseModality::Text => Ok(Self::Text),
580            WireLiveResponseModality::Unknown { debug } => {
581                Err(WireConversionError::ResponseModality { debug })
582            }
583        }
584    }
585}
586
587/// Request payload for `live/commit_input`.
588///
589/// G9: optional `response_modality` lets the caller request a text-only
590/// response on an otherwise-audio channel without flipping the channel
591/// modality. `None` keeps the channel default (`audio` for the OpenAI
592/// realtime adapter today).
593#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
594#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
595pub struct LiveCommitInputParams {
596    pub channel_id: String,
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub response_modality: Option<WireLiveResponseModality>,
599}
600
601/// Response payload for `live/status`.
602///
603/// R6-3 (P2): `status` is now the typed [`WireLiveAdapterStatus`] mirror
604/// (with R5-3's `Unknown { debug }` floor) instead of the core
605/// [`LiveAdapterStatus`] under a `schemars(with = "serde_json::Value")`
606/// shroud. SDK codegen emits the typed discriminated union (TS) / typed
607/// dict union (Python) for `live/status` instead of `unknown` / `Any`.
608/// The runtime handler converts core → wire at the boundary; clients
609/// that fully understood the previous JSON shape see byte-identical
610/// payloads (the wire mirror serializes byte-compatible with the core
611/// enum — see `wire_live_adapter_status_byte_compatible_with_core`).
612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
613#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
614pub struct LiveStatusResult {
615    pub channel_id: String,
616    pub status: WireLiveAdapterStatus,
617}
618
619/// Status of a `live/refresh` request relative to the adapter pump.
620///
621/// R4-5 (P3): the refresh path is asynchronous — host queue acceptance happens
622/// before the adapter pump has applied the resulting `session.update`. The
623/// realtime stream is the source of truth for the actual refresh outcome
624/// (failures surface as `LiveAdapterObservation::Error`).
625///
626/// Today generated runtime authority emits only `Queued`. The enum is
627/// `#[non_exhaustive]` so a future generated contract can add a typed status
628/// without changing the object shape. SDK consumers must route on the string
629/// value and fail closed for values outside the generated contract they were
630/// built with.
631///
632/// Serializes as a plain string (no envelope) so [`LiveRefreshResult`] keeps
633/// SDK codegen on the simple-struct path.
634#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
635#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
636#[serde(rename_all = "snake_case")]
637#[non_exhaustive]
638pub enum LiveRefreshStatus {
639    /// The host has accepted the refresh command onto the adapter queue. The
640    /// adapter pump applies the `session.update` asynchronously; callers that
641    /// need the actual outcome must observe the adapter's realtime stream.
642    Queued,
643}
644
645/// Response payload for `live/refresh`.
646///
647/// R4-5 (P3): replaces the previous untyped `{"refresh_enqueued": true}`
648/// JSON blob with the typed `status` discriminator. Clients route on
649/// `status`.
650///
651/// See [`LiveRefreshStatus`] for the variant set and the contract on
652/// asynchronous adapter-pump application.
653#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
654#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
655pub struct LiveRefreshResult {
656    /// Typed refresh status emitted by generated runtime authority.
657    /// Today: always [`LiveRefreshStatus::Queued`].
658    pub status: LiveRefreshStatus,
659}
660
661impl LiveRefreshResult {
662    /// Project the generated `Queued` authority result to the wire shape.
663    pub fn queued() -> Self {
664        Self {
665            status: LiveRefreshStatus::Queued,
666        }
667    }
668}
669
670/// Typed public result class for `live/close`.
671///
672/// Today generated runtime authority emits only `Closed`. The enum is
673/// `#[non_exhaustive]` so future generated contracts can add explicit result
674/// classes without changing the object shape.
675#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677#[serde(rename_all = "snake_case")]
678#[non_exhaustive]
679pub enum LiveCloseStatus {
680    /// The host has accepted the close handoff and released channel transport
681    /// resources on a best-effort basis.
682    Closed,
683}
684
685/// Response payload for `live/close`.
686///
687/// Clients route on the typed `status` discriminator.
688#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
689#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
690pub struct LiveCloseResult {
691    /// Typed close status emitted by generated runtime authority.
692    /// Today: always [`LiveCloseStatus::Closed`].
693    pub status: LiveCloseStatus,
694}
695
696impl LiveCloseResult {
697    /// Project the generated `Closed` authority result to the wire shape.
698    pub fn closed() -> Self {
699        Self {
700            status: LiveCloseStatus::Closed,
701        }
702    }
703}
704
705// ---------------------------------------------------------------------------
706// #234 — typed live-command results
707//
708// `live/send_input`, `live/commit_input`, `live/interrupt`, and
709// `live/truncate` previously returned ad-hoc `serde_json::json!({"sent":
710// true})` blobs from the RPC handler — untyped at the SDK boundary and
711// invisible to schema codegen. These typed result shapes mirror the
712// `LiveCloseResult` / `LiveCloseStatus` precedent above: a typed `status`
713// discriminator emitted by generated runtime authority. Each `status` enum is
714// `#[non_exhaustive]` so future generated contracts can add explicit result
715// classes without reshaping the object.
716// ---------------------------------------------------------------------------
717
718/// Typed public result class for `live/send_input`.
719///
720/// Today generated runtime authority emits only `Sent`. The enum is
721/// `#[non_exhaustive]` so future generated contracts can add explicit result
722/// classes without changing the object shape.
723#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
725#[serde(rename_all = "snake_case")]
726#[non_exhaustive]
727pub enum LiveSendInputStatus {
728    /// The host accepted the input chunk onto the adapter queue.
729    Sent,
730}
731
732/// Response payload for `live/send_input`.
733///
734/// Clients route on the typed `status` discriminator.
735#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
736#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
737pub struct LiveSendInputResult {
738    /// Typed send-input status emitted by generated runtime authority.
739    /// Today: always [`LiveSendInputStatus::Sent`].
740    pub status: LiveSendInputStatus,
741}
742
743impl LiveSendInputResult {
744    /// Project the generated `Sent` authority result to the wire shape.
745    pub fn sent() -> Self {
746        Self {
747            status: LiveSendInputStatus::Sent,
748        }
749    }
750}
751
752/// Typed public result class for `live/commit_input`.
753///
754/// Today generated runtime authority emits only `Committed`. The enum is
755/// `#[non_exhaustive]` so future generated contracts can add explicit result
756/// classes without changing the object shape.
757#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
758#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
759#[serde(rename_all = "snake_case")]
760#[non_exhaustive]
761pub enum LiveCommitInputStatus {
762    /// The host accepted the commit-input command onto the adapter queue.
763    Committed,
764}
765
766/// Response payload for `live/commit_input`.
767///
768/// Clients route on the typed `status` discriminator.
769#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
770#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
771pub struct LiveCommitInputResult {
772    /// Typed commit-input status emitted by generated runtime authority.
773    /// Today: always [`LiveCommitInputStatus::Committed`].
774    pub status: LiveCommitInputStatus,
775}
776
777impl LiveCommitInputResult {
778    /// Project the generated `Committed` authority result to the wire shape.
779    pub fn committed() -> Self {
780        Self {
781            status: LiveCommitInputStatus::Committed,
782        }
783    }
784}
785
786/// Typed public result class for `live/interrupt`.
787///
788/// Today generated runtime authority emits only `Interrupted`. The enum is
789/// `#[non_exhaustive]` so future generated contracts can add explicit result
790/// classes without changing the object shape.
791#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
792#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
793#[serde(rename_all = "snake_case")]
794#[non_exhaustive]
795pub enum LiveInterruptStatus {
796    /// The host accepted the interrupt command onto the adapter queue.
797    Interrupted,
798}
799
800/// Response payload for `live/interrupt`.
801///
802/// Clients route on the typed `status` discriminator.
803#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805pub struct LiveInterruptResult {
806    /// Typed interrupt status emitted by generated runtime authority.
807    /// Today: always [`LiveInterruptStatus::Interrupted`].
808    pub status: LiveInterruptStatus,
809}
810
811impl LiveInterruptResult {
812    /// Project the generated `Interrupted` authority result to the wire shape.
813    pub fn interrupted() -> Self {
814        Self {
815            status: LiveInterruptStatus::Interrupted,
816        }
817    }
818}
819
820/// Typed public result class for `live/truncate`.
821///
822/// Today generated runtime authority emits only `Truncated`. The enum is
823/// `#[non_exhaustive]` so future generated contracts can add explicit result
824/// classes without changing the object shape.
825#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
826#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
827#[serde(rename_all = "snake_case")]
828#[non_exhaustive]
829pub enum LiveTruncateStatus {
830    /// The host accepted the truncate command onto the adapter queue.
831    Truncated,
832}
833
834/// Response payload for `live/truncate`.
835///
836/// Clients route on the typed `status` discriminator.
837#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
838#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
839pub struct LiveTruncateResult {
840    /// Typed truncate status emitted by generated runtime authority.
841    /// Today: always [`LiveTruncateStatus::Truncated`].
842    pub status: LiveTruncateStatus,
843}
844
845impl LiveTruncateResult {
846    /// Project the generated `Truncated` authority result to the wire shape.
847    pub fn truncated() -> Self {
848        Self {
849            status: LiveTruncateStatus::Truncated,
850        }
851    }
852}
853
854/// Modality-tagged input chunk for `live/send_input`.
855///
856/// Audio / image / video-frame payloads are base64 strings (`data`); the
857/// modality-specific metadata (`sample_rate_hz` / `channels` for audio,
858/// `mime` for image, `codec` / `timestamp_ms` for video frames) lets the
859/// adapter validate against the negotiated provider format.
860///
861/// T11: `Image` and `VideoFrame` mirror the typed variants on
862/// [`meerkat_core::live_adapter::LiveInputChunk`]. Adapters that do not
863/// implement a variant must reject with a typed
864/// `LiveAdapterErrorCode::ConfigRejected` rather than collapsing onto a
865/// free-form provider error string.
866#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
867#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
868#[serde(tag = "kind", rename_all = "snake_case")]
869pub enum LiveInputChunkWire {
870    Audio {
871        data: String,
872        sample_rate_hz: u32,
873        channels: u16,
874    },
875    Text {
876        text: String,
877    },
878    Image {
879        mime: String,
880        data: String,
881    },
882    VideoFrame {
883        codec: String,
884        data: String,
885        timestamp_ms: u64,
886    },
887}
888
889/// Request payload for `live/send_input`.
890///
891/// **`BREAKING_LIVE_WIRE_FORMAT_V1`** (H48): `chunk` is a nested object, not
892/// a flattened sibling of `channel_id`. WS protocol clients that piggyback on
893/// this shape must use the nested form.
894#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
895#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
896pub struct LiveSendInputParams {
897    pub channel_id: String,
898    pub chunk: LiveInputChunkWire,
899}
900
901/// Request payload for `live/truncate`.
902///
903/// `item_id` and `content_index` are the provider-side handle for the
904/// assistant item being truncated; `audio_played_ms` is the client-tracked
905/// playback cursor at the moment of truncation. There is no server-side
906/// playback-cursor read API — clients track playback locally and pass the
907/// cursor in here when they want to truncate.
908#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
910pub struct LiveTruncateParams {
911    pub channel_id: String,
912    pub item_id: String,
913    pub content_index: u32,
914    pub audio_played_ms: u64,
915}
916
917// ---------------------------------------------------------------------------
918// FIX-SDK-OBS — typed wire mirror for `LiveAdapterObservation`
919// ---------------------------------------------------------------------------
920
921/// Wire mirror of [`meerkat_core::live_adapter::LiveDegradationReason`].
922///
923/// Internally-tagged on `kind` (snake_case) — matches the core enum's serde
924/// shape exactly. SDK consumers route on `kind` to distinguish the
925/// non-payload variants from the typed `other { detail }` payload.
926#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
927#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
928#[serde(tag = "kind", rename_all = "snake_case")]
929#[non_exhaustive]
930pub enum WireLiveDegradationReason {
931    RateLimited,
932    ProviderThrottled,
933    NetworkUnstable,
934    Other { detail: String },
935    Unknown { debug: String },
936}
937
938impl From<LiveDegradationReason> for WireLiveDegradationReason {
939    fn from(value: LiveDegradationReason) -> Self {
940        match value {
941            LiveDegradationReason::RateLimited => Self::RateLimited,
942            LiveDegradationReason::ProviderThrottled => Self::ProviderThrottled,
943            LiveDegradationReason::NetworkUnstable => Self::NetworkUnstable,
944            LiveDegradationReason::Other { detail } => Self::Other {
945                detail: detail.into_owned(),
946            },
947            other => {
948                debug_assert!(
949                    false,
950                    "WireLiveDegradationReason::from saw an unmapped \
951                     LiveDegradationReason variant: {other:?}; add an explicit arm in \
952                     meerkat-contracts/src/wire/live.rs."
953                );
954                Self::Unknown {
955                    debug: format!("{other:?}"),
956                }
957            }
958        }
959    }
960}
961
962impl TryFrom<WireLiveDegradationReason> for LiveDegradationReason {
963    type Error = WireConversionError;
964
965    fn try_from(value: WireLiveDegradationReason) -> Result<Self, Self::Error> {
966        match value {
967            WireLiveDegradationReason::RateLimited => Ok(Self::RateLimited),
968            WireLiveDegradationReason::ProviderThrottled => Ok(Self::ProviderThrottled),
969            WireLiveDegradationReason::NetworkUnstable => Ok(Self::NetworkUnstable),
970            WireLiveDegradationReason::Other { detail } => Ok(Self::Other {
971                detail: detail.into(),
972            }),
973            WireLiveDegradationReason::Unknown { debug } => {
974                Err(WireConversionError::DegradationReason { debug })
975            }
976        }
977    }
978}
979
980/// Wire mirror of [`meerkat_core::live_adapter::LiveAdapterStatus`].
981///
982/// Internally-tagged on `status` (snake_case). The `degraded` variant
983/// references [`WireLiveDegradationReason`] so the typed reason is visible at
984/// the SDK boundary.
985#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
986#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
987#[serde(tag = "status", rename_all = "snake_case")]
988#[non_exhaustive]
989pub enum WireLiveAdapterStatus {
990    Idle,
991    Opening,
992    Ready,
993    Degraded {
994        reason: WireLiveDegradationReason,
995    },
996    Closing,
997    Closed,
998    /// R5-3 (P3): explicit fail-loud variant for unknown core variants.
999    ///
1000    /// The core [`LiveAdapterStatus`] enum is `#[non_exhaustive]`. When a
1001    /// future status lands without an explicit arm in the wire `From`
1002    /// impl, the conversion surfaces as `Unknown { debug }` rather than
1003    /// silently coercing to `Closed` — a "plausible lie" that would tell
1004    /// SDK consumers a healthy channel was torn down. SDK consumers route
1005    /// on the `status: "unknown"` discriminator and treat it as an
1006    /// unrecognized status — never as `Closed`.
1007    ///
1008    /// **When a new core variant is added, add an explicit arm in the
1009    /// forward `From` impl above this variant — `Unknown` is the floor,
1010    /// not the destination.**
1011    Unknown {
1012        debug: String,
1013    },
1014}
1015
1016impl From<LiveAdapterStatus> for WireLiveAdapterStatus {
1017    fn from(value: LiveAdapterStatus) -> Self {
1018        match value {
1019            LiveAdapterStatus::Idle => Self::Idle,
1020            LiveAdapterStatus::Opening => Self::Opening,
1021            LiveAdapterStatus::Ready => Self::Ready,
1022            LiveAdapterStatus::Degraded { reason } => Self::Degraded {
1023                reason: reason.into(),
1024            },
1025            LiveAdapterStatus::Closing => Self::Closing,
1026            LiveAdapterStatus::Closed => Self::Closed,
1027            // Core enum is `#[non_exhaustive]`. R5-3 (P3): surface unknown
1028            // variants explicitly via `Unknown { debug }` rather than
1029            // silently coercing to `Closed` (the previous fail-open default
1030            // — a plausible lie that would tell consumers a healthy
1031            // channel was torn down). When a new core variant lands, add
1032            // an explicit arm above this comment.
1033            other => {
1034                debug_assert!(
1035                    false,
1036                    "WireLiveAdapterStatus::from saw an unmapped \
1037                     LiveAdapterStatus variant; add an explicit arm in \
1038                     meerkat-contracts/src/wire/live.rs."
1039                );
1040                Self::Unknown {
1041                    debug: format!("{other:?}"),
1042                }
1043            }
1044        }
1045    }
1046}
1047
1048// (No `impl From/TryFrom<WireLiveAdapterStatus> for LiveAdapterStatus`
1049// emitted: like `WireLiveAdapterObservation`, the wire-side adapter status
1050// is a downstream projection — never authority. The `Unknown` arm exists
1051// so future core variants surface explicitly on the forward path; the
1052// `WireConversionError::Status` variant is reserved for a future
1053// caller that needs to construct a typed inverse and will materialize the
1054// reverse impl alongside the dependent `From<WireLiveDegradationReason>`
1055// addition. No production code calls the inverse today.)
1056
1057/// Wire mirror of [`meerkat_core::live_adapter::LiveAdapterErrorCode`].
1058///
1059/// Internally-tagged on `code` (snake_case). SDK consumers route on `code`
1060/// to distinguish payload-less variants from typed payload variants
1061/// (`config_rejected { reason }`, `other { raw }`). FIX-SDK-OBS: makes the
1062/// R5-9 `CommandRejected` observation's typed code visible at the SDK
1063/// boundary instead of a free-form blob.
1064///
1065/// R5-2 (P2 dogma): `config_rejected.reason` is now a typed
1066/// [`WireLiveConfigRejectionReason`] mirror so SDK consumers route on the
1067/// variant rather than parsing English from the previous free-form
1068/// `String`.
1069#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1070#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1071#[serde(tag = "code", rename_all = "snake_case")]
1072#[non_exhaustive]
1073pub enum WireLiveAdapterErrorCode {
1074    ConnectionFailed,
1075    ConnectionLost,
1076    ConfigRejected {
1077        reason: WireLiveConfigRejectionReason,
1078    },
1079    ProviderError,
1080    AuthenticationFailed,
1081    InternalError,
1082    Other {
1083        raw: String,
1084    },
1085    /// R5-3 (P3): explicit fail-loud variant for unknown core variants.
1086    ///
1087    /// The core [`LiveAdapterErrorCode`] enum is `#[non_exhaustive]`. When
1088    /// a future error code lands without an explicit arm in the wire
1089    /// `From` impl, the conversion surfaces as `Unknown { debug }` rather
1090    /// than silently coercing to `InternalError` — a "plausible lie" that
1091    /// would attribute every future-variant failure to an internal-server
1092    /// bug and mask real provider/transport classification. SDK consumers
1093    /// route on the `code: "unknown"` discriminator and treat it as an
1094    /// unrecognized error class — never as `InternalError`.
1095    ///
1096    /// **When a new core variant is added, add an explicit arm in the
1097    /// forward `From` impl above this variant — `Unknown` is the floor,
1098    /// not the destination.**
1099    Unknown {
1100        debug: String,
1101    },
1102}
1103
1104/// Wire mirror of
1105/// [`meerkat_core::live_adapter::LiveConfigRejectionReason`]. R5-2 (P2
1106/// dogma): pins the typed semantic-routing variants on the wire so SDKs
1107/// can distinguish a runtime-side identity swap from an adapter-side
1108/// input-modality rejection without string parsing.
1109///
1110/// R6-5 (P3 dogma): closes the last typed-route-as-detail-string gap. The
1111/// previous wildcard fallback collapsed future core variants into
1112/// `Other { detail: "unknown_live_config_rejection_reason" }`, which SDK
1113/// consumers had to pattern-match on the English `detail` to route — the
1114/// exact "typed route becomes detail string" antipattern R3-6 + R5-3
1115/// closed for transport / observation / continuity / modality / status /
1116/// error_code. Future core variants now surface as `Unknown { debug }`
1117/// with the `{:?}` projection of the source variant preserved for
1118/// server-side observability.
1119fn bool_is_false(value: &bool) -> bool {
1120    !*value
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1125#[serde(tag = "kind", rename_all = "snake_case")]
1126#[non_exhaustive]
1127pub enum WireLiveConfigRejectionReason {
1128    ChannelIdentitySwap {
1129        from_model: String,
1130        from_provider: WireProvider,
1131        to_model: String,
1132        to_provider: WireProvider,
1133        #[serde(default, skip_serializing_if = "bool_is_false")]
1134        auth_binding_changed: bool,
1135    },
1136    NonRealtimeResolution {
1137        detail: String,
1138    },
1139    ImageInputNotImplemented,
1140    VideoFrameInputNotImplemented,
1141    UnsupportedInputChunkVariant,
1142    RefreshModelSwap {
1143        from_model: String,
1144        to_model: String,
1145    },
1146    RefreshProviderSwap {
1147        from_provider: WireProvider,
1148        to_provider: WireProvider,
1149    },
1150    RefreshAudioConfigMismatch {
1151        detail: String,
1152    },
1153    /// R6-4 (P2): typed mirror of
1154    /// [`LiveConfigRejectionReason::AudioInputFormatMismatch`]. The
1155    /// bound provider session has a fixed input audio format (OpenAI
1156    /// Realtime: PCM 24 kHz mono); a `live/send_input` chunk that
1157    /// declares a divergent `sample_rate_hz` / `channels` is rejected
1158    /// at the adapter boundary BEFORE bytes hit the provider buffer.
1159    /// SDK consumers route on this typed discriminator to surface a
1160    /// precise format-mismatch error rather than parsing English from
1161    /// `Other.detail`.
1162    AudioInputFormatMismatch {
1163        expected_sample_rate_hz: u32,
1164        expected_channels: u16,
1165        actual_sample_rate_hz: u32,
1166        actual_channels: u16,
1167    },
1168    Other {
1169        detail: String,
1170    },
1171    /// R6-5 (P3 dogma): explicit fail-loud variant for unknown core
1172    /// variants.
1173    ///
1174    /// The core [`LiveConfigRejectionReason`] enum is `#[non_exhaustive]`.
1175    /// When a future variant lands without an explicit arm in the wire
1176    /// `From` impl, the conversion surfaces as `Unknown { debug }` rather
1177    /// than silently coercing into `Other { detail:
1178    /// "unknown_live_config_rejection_reason" }` — a "typed route becomes
1179    /// detail string" antipattern that forces SDK consumers to parse
1180    /// English from `detail` to recover the missing route. SDK consumers
1181    /// route on the `kind: "unknown"` discriminator and treat it as an
1182    /// unrecognized rejection reason — never as `Other`. The `debug`
1183    /// payload carries the `{:?}` projection of the source variant so
1184    /// server logs name the real shape that needs to be promoted.
1185    ///
1186    /// **When a new core variant is added, add an explicit arm in the
1187    /// forward `From` impl above this variant — `Unknown` is the floor,
1188    /// not the destination.**
1189    Unknown {
1190        debug: String,
1191    },
1192}
1193
1194impl From<LiveConfigRejectionReason> for WireLiveConfigRejectionReason {
1195    fn from(value: LiveConfigRejectionReason) -> Self {
1196        match value {
1197            LiveConfigRejectionReason::ChannelIdentitySwap {
1198                from_model,
1199                from_provider,
1200                to_model,
1201                to_provider,
1202                auth_binding_changed,
1203            } => Self::ChannelIdentitySwap {
1204                from_model,
1205                from_provider: from_provider.into(),
1206                to_model,
1207                to_provider: to_provider.into(),
1208                auth_binding_changed,
1209            },
1210            LiveConfigRejectionReason::NonRealtimeResolution { detail } => {
1211                Self::NonRealtimeResolution { detail }
1212            }
1213            LiveConfigRejectionReason::ImageInputNotImplemented => Self::ImageInputNotImplemented,
1214            LiveConfigRejectionReason::VideoFrameInputNotImplemented => {
1215                Self::VideoFrameInputNotImplemented
1216            }
1217            LiveConfigRejectionReason::UnsupportedInputChunkVariant => {
1218                Self::UnsupportedInputChunkVariant
1219            }
1220            LiveConfigRejectionReason::RefreshModelSwap {
1221                from_model,
1222                to_model,
1223            } => Self::RefreshModelSwap {
1224                from_model,
1225                to_model,
1226            },
1227            LiveConfigRejectionReason::RefreshProviderSwap {
1228                from_provider,
1229                to_provider,
1230            } => Self::RefreshProviderSwap {
1231                from_provider: from_provider.into(),
1232                to_provider: to_provider.into(),
1233            },
1234            LiveConfigRejectionReason::RefreshAudioConfigMismatch { detail } => {
1235                Self::RefreshAudioConfigMismatch { detail }
1236            }
1237            LiveConfigRejectionReason::AudioInputFormatMismatch {
1238                expected_sample_rate_hz,
1239                expected_channels,
1240                actual_sample_rate_hz,
1241                actual_channels,
1242            } => Self::AudioInputFormatMismatch {
1243                expected_sample_rate_hz,
1244                expected_channels,
1245                actual_sample_rate_hz,
1246                actual_channels,
1247            },
1248            LiveConfigRejectionReason::Other { detail } => Self::Other { detail },
1249            // Core enum is `#[non_exhaustive]`. R6-5 (P3 dogma): surface
1250            // unknown variants explicitly via `Unknown { debug }` rather
1251            // than silently coercing to `Other { detail:
1252            // "unknown_live_config_rejection_reason" }` (the previous
1253            // fail-open default — a "typed route becomes detail string"
1254            // antipattern that forced SDK consumers to parse English from
1255            // `detail` to recover the missing route). When a new core
1256            // variant lands, add an explicit arm above this comment.
1257            other => {
1258                debug_assert!(
1259                    false,
1260                    "WireLiveConfigRejectionReason::from saw an unmapped \
1261                     LiveConfigRejectionReason variant; add an explicit arm \
1262                     in meerkat-contracts/src/wire/live.rs."
1263                );
1264                Self::Unknown {
1265                    debug: format!("{other:?}"),
1266                }
1267            }
1268        }
1269    }
1270}
1271
1272impl TryFrom<WireLiveConfigRejectionReason> for LiveConfigRejectionReason {
1273    type Error = WireConversionError;
1274
1275    fn try_from(value: WireLiveConfigRejectionReason) -> Result<Self, Self::Error> {
1276        // No wildcard arm: `WireLiveConfigRejectionReason` is owned by this
1277        // crate so even with `#[non_exhaustive]` (which only constrains
1278        // matches outside the defining crate) the compiler enforces
1279        // exhaustive coverage here. Adding a new wire variant therefore
1280        // forces a new arm in this match — no silent fallthrough.
1281        match value {
1282            WireLiveConfigRejectionReason::ChannelIdentitySwap {
1283                from_model,
1284                from_provider,
1285                to_model,
1286                to_provider,
1287                auth_binding_changed,
1288            } => Ok(Self::ChannelIdentitySwap {
1289                from_model,
1290                from_provider: from_provider.try_into()?,
1291                to_model,
1292                to_provider: to_provider.try_into()?,
1293                auth_binding_changed,
1294            }),
1295            WireLiveConfigRejectionReason::NonRealtimeResolution { detail } => {
1296                Ok(Self::NonRealtimeResolution { detail })
1297            }
1298            WireLiveConfigRejectionReason::ImageInputNotImplemented => {
1299                Ok(Self::ImageInputNotImplemented)
1300            }
1301            WireLiveConfigRejectionReason::VideoFrameInputNotImplemented => {
1302                Ok(Self::VideoFrameInputNotImplemented)
1303            }
1304            WireLiveConfigRejectionReason::UnsupportedInputChunkVariant => {
1305                Ok(Self::UnsupportedInputChunkVariant)
1306            }
1307            WireLiveConfigRejectionReason::RefreshModelSwap {
1308                from_model,
1309                to_model,
1310            } => Ok(Self::RefreshModelSwap {
1311                from_model,
1312                to_model,
1313            }),
1314            WireLiveConfigRejectionReason::RefreshProviderSwap {
1315                from_provider,
1316                to_provider,
1317            } => Ok(Self::RefreshProviderSwap {
1318                from_provider: from_provider.try_into()?,
1319                to_provider: to_provider.try_into()?,
1320            }),
1321            WireLiveConfigRejectionReason::RefreshAudioConfigMismatch { detail } => {
1322                Ok(Self::RefreshAudioConfigMismatch { detail })
1323            }
1324            WireLiveConfigRejectionReason::AudioInputFormatMismatch {
1325                expected_sample_rate_hz,
1326                expected_channels,
1327                actual_sample_rate_hz,
1328                actual_channels,
1329            } => Ok(Self::AudioInputFormatMismatch {
1330                expected_sample_rate_hz,
1331                expected_channels,
1332                actual_sample_rate_hz,
1333                actual_channels,
1334            }),
1335            WireLiveConfigRejectionReason::Other { detail } => Ok(Self::Other { detail }),
1336            WireLiveConfigRejectionReason::Unknown { debug } => {
1337                Err(WireConversionError::ConfigRejectionReason { debug })
1338            }
1339        }
1340    }
1341}
1342
1343impl From<LiveAdapterErrorCode> for WireLiveAdapterErrorCode {
1344    fn from(value: LiveAdapterErrorCode) -> Self {
1345        match value {
1346            LiveAdapterErrorCode::ConnectionFailed => Self::ConnectionFailed,
1347            LiveAdapterErrorCode::ConnectionLost => Self::ConnectionLost,
1348            LiveAdapterErrorCode::ConfigRejected { reason } => Self::ConfigRejected {
1349                reason: reason.into(),
1350            },
1351            LiveAdapterErrorCode::ProviderError => Self::ProviderError,
1352            LiveAdapterErrorCode::AuthenticationFailed => Self::AuthenticationFailed,
1353            LiveAdapterErrorCode::InternalError => Self::InternalError,
1354            LiveAdapterErrorCode::Other { raw } => Self::Other { raw },
1355            // Core enum is `#[non_exhaustive]`. R5-3 (P3): surface unknown
1356            // variants explicitly via `Unknown { debug }` rather than
1357            // silently coercing to `InternalError` (the previous fail-open
1358            // default — a plausible lie that would attribute every
1359            // future-variant failure to an internal-server bug). When a
1360            // new core variant lands, add an explicit arm above this
1361            // comment.
1362            other => {
1363                debug_assert!(
1364                    false,
1365                    "WireLiveAdapterErrorCode::from saw an unmapped \
1366                     LiveAdapterErrorCode variant; add an explicit arm in \
1367                     meerkat-contracts/src/wire/live.rs."
1368                );
1369                Self::Unknown {
1370                    debug: format!("{other:?}"),
1371                }
1372            }
1373        }
1374    }
1375}
1376
1377impl TryFrom<WireLiveAdapterErrorCode> for LiveAdapterErrorCode {
1378    type Error = WireConversionError;
1379
1380    fn try_from(value: WireLiveAdapterErrorCode) -> Result<Self, Self::Error> {
1381        // No wildcard arm: `WireLiveAdapterErrorCode` is owned by this
1382        // crate so even with `#[non_exhaustive]` the compiler enforces
1383        // exhaustive coverage here.
1384        match value {
1385            WireLiveAdapterErrorCode::ConnectionFailed => Ok(Self::ConnectionFailed),
1386            WireLiveAdapterErrorCode::ConnectionLost => Ok(Self::ConnectionLost),
1387            WireLiveAdapterErrorCode::ConfigRejected { reason } => Ok(Self::ConfigRejected {
1388                reason: reason.try_into()?,
1389            }),
1390            WireLiveAdapterErrorCode::ProviderError => Ok(Self::ProviderError),
1391            WireLiveAdapterErrorCode::AuthenticationFailed => Ok(Self::AuthenticationFailed),
1392            WireLiveAdapterErrorCode::InternalError => Ok(Self::InternalError),
1393            WireLiveAdapterErrorCode::Other { raw } => Ok(Self::Other { raw }),
1394            WireLiveAdapterErrorCode::Unknown { debug } => {
1395                Err(WireConversionError::ErrorCode { debug })
1396            }
1397        }
1398    }
1399}
1400
1401/// Wire mirror of [`meerkat_core::live_adapter::LiveAdapterObservation`].
1402///
1403/// FIX-SDK-OBS: closes the R5-4 verifier gap. The core enum is the canonical
1404/// shape adapters emit, but it is not registered for schema emission and is
1405/// therefore invisible at the SDK boundary — browser/Python clients receive
1406/// observations as untyped JSON and cannot type-narrow on
1407/// `assistant_audio_chunk` (to read the new `item_id` / `response_id` /
1408/// `content_index` fields driving `live/truncate`) or on `command_rejected`
1409/// (a typed channel-survives error introduced in R5-9). The wire mirror
1410/// makes every variant visible to schema codegen and produces a discriminated
1411/// TypeScript union / typed Python `TypedDict` union.
1412///
1413/// Serde shape mirrors the core enum exactly: internally-tagged on
1414/// `observation` (snake_case). Round-trip with the core type is byte-
1415/// identical (see `wire_live_adapter_observation_byte_compatible_with_core`).
1416///
1417/// Field types reference other wire mirrors where they exist
1418/// ([`WireStopReason`], [`WireUsage`], [`WireLiveAdapterStatus`],
1419/// [`WireLiveAdapterErrorCode`]) and the canonical
1420/// [`RealtimeTranscriptEvent`] (which already derives `JsonSchema` and is
1421/// auto-promoted by the SDK codegen `Realtime*` allowlist rule).
1422///
1423/// Audio data is base64-encoded on the wire (matches the core
1424/// [`LiveAdapterObservation::AssistantAudioChunk`] base64 mode); the wire
1425/// mirror carries `data` as a `String` so the schema emits `String` instead
1426/// of an opaque `Vec<u8>` JSON-array shape.
1427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1428#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1429#[serde(tag = "observation", rename_all = "snake_case")]
1430#[non_exhaustive]
1431pub enum WireLiveAdapterObservation {
1432    Ready,
1433    UserTranscriptFinal {
1434        #[serde(default, skip_serializing_if = "Option::is_none")]
1435        provider_item_id: Option<String>,
1436        #[serde(default, skip_serializing_if = "Option::is_none")]
1437        previous_item_id: Option<String>,
1438        #[serde(default, skip_serializing_if = "Option::is_none")]
1439        content_index: Option<u32>,
1440        text: String,
1441    },
1442    AssistantTextDelta {
1443        #[serde(default, skip_serializing_if = "Option::is_none")]
1444        provider_item_id: Option<String>,
1445        #[serde(default, skip_serializing_if = "Option::is_none")]
1446        previous_item_id: Option<String>,
1447        #[serde(default, skip_serializing_if = "Option::is_none")]
1448        content_index: Option<u32>,
1449        #[serde(default, skip_serializing_if = "Option::is_none")]
1450        response_id: Option<String>,
1451        #[serde(default, skip_serializing_if = "Option::is_none")]
1452        delta_id: Option<String>,
1453        delta: String,
1454    },
1455    AssistantTranscriptDelta {
1456        #[serde(default, skip_serializing_if = "Option::is_none")]
1457        provider_item_id: Option<String>,
1458        #[serde(default, skip_serializing_if = "Option::is_none")]
1459        previous_item_id: Option<String>,
1460        #[serde(default, skip_serializing_if = "Option::is_none")]
1461        content_index: Option<u32>,
1462        #[serde(default, skip_serializing_if = "Option::is_none")]
1463        response_id: Option<String>,
1464        #[serde(default, skip_serializing_if = "Option::is_none")]
1465        delta_id: Option<String>,
1466        delta: String,
1467    },
1468    /// R5-4: identity fields (`response_id`, `item_id`, `content_index`)
1469    /// propagate the source server-event identity so clients can attach a
1470    /// playback cursor to a provider item without racing on the
1471    /// transcript-delta arrival order.
1472    AssistantAudioChunk {
1473        /// Base64-encoded PCM payload. Matches the core
1474        /// [`LiveAdapterObservation::AssistantAudioChunk`] serde shape.
1475        data: String,
1476        sample_rate_hz: u32,
1477        channels: u16,
1478        #[serde(default, skip_serializing_if = "Option::is_none")]
1479        response_id: Option<String>,
1480        #[serde(default, skip_serializing_if = "Option::is_none")]
1481        item_id: Option<String>,
1482        #[serde(default, skip_serializing_if = "Option::is_none")]
1483        content_index: Option<u32>,
1484    },
1485    AssistantTranscriptFinal {
1486        provider_item_id: String,
1487        #[serde(default, skip_serializing_if = "Option::is_none")]
1488        previous_item_id: Option<String>,
1489        #[serde(default, skip_serializing_if = "Option::is_none")]
1490        content_index: Option<u32>,
1491        #[serde(default, skip_serializing_if = "Option::is_none")]
1492        response_id: Option<String>,
1493        text: String,
1494        stop_reason: WireStopReason,
1495        usage: crate::wire::WireUsage,
1496    },
1497    AssistantTranscriptTruncated {
1498        #[serde(default, skip_serializing_if = "Option::is_none")]
1499        provider_item_id: Option<String>,
1500        #[serde(default, skip_serializing_if = "Option::is_none")]
1501        previous_item_id: Option<String>,
1502        #[serde(default, skip_serializing_if = "Option::is_none")]
1503        content_index: Option<u32>,
1504        #[serde(default, skip_serializing_if = "Option::is_none")]
1505        response_id: Option<String>,
1506        #[serde(default, skip_serializing_if = "Option::is_none")]
1507        text: Option<String>,
1508    },
1509    /// Pass-through of a structured `RealtimeTranscriptEvent` from the
1510    /// provider. The core type already derives `JsonSchema` and the SDK
1511    /// codegen auto-promotes `Realtime*` schemas, so the typed shape lands
1512    /// in generated SDK types without an additional wire mirror.
1513    RealtimeTranscript {
1514        event: RealtimeTranscriptEvent,
1515    },
1516    ToolCallRequested {
1517        provider_call_id: String,
1518        tool_name: String,
1519        #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
1520        arguments: serde_json::Value,
1521    },
1522    /// Barge-in: the user interrupted the assistant mid-turn.
1523    ///
1524    /// G4 (P1): `response_id` carries the in-flight provider response id so
1525    /// downstream consumers can scope the truncation to the right response
1526    /// even when the interrupt arrives before any transcript delta has been
1527    /// staged.
1528    TurnInterrupted {
1529        #[serde(default, skip_serializing_if = "Option::is_none")]
1530        response_id: Option<String>,
1531    },
1532    TurnCompleted {
1533        #[serde(default, skip_serializing_if = "Option::is_none")]
1534        response_id: Option<String>,
1535        stop_reason: WireStopReason,
1536        usage: crate::wire::WireUsage,
1537    },
1538    StatusChanged {
1539        status: WireLiveAdapterStatus,
1540    },
1541    Error {
1542        code: WireLiveAdapterErrorCode,
1543        message: String,
1544    },
1545    /// R5-9: scoped command rejection. Channel survives — distinct from
1546    /// terminal [`Self::Error`].
1547    CommandRejected {
1548        code: WireLiveAdapterErrorCode,
1549        message: String,
1550    },
1551    /// R3-6 (P2): explicit fail-loud variant for unknown core variants.
1552    ///
1553    /// The core [`LiveAdapterObservation`] enum is `#[non_exhaustive]`. When
1554    /// a future variant lands without an explicit arm in the wire `From`
1555    /// impl, the conversion surfaces as `Unknown { debug }` rather than
1556    /// silently coercing into the worst-possible default —
1557    /// `TurnInterrupted` — which would surface a real new event as an
1558    /// interrupt and drop data downstream. SDK consumers route on the
1559    /// `observation: "unknown"` discriminator and treat it as an
1560    /// unrecognized event — never as a barge-in. The `debug` payload
1561    /// carries the `{:?}` projection of the source variant so server logs
1562    /// name the real shape that needs to be promoted.
1563    ///
1564    /// **When a new core variant is added, add an explicit arm in the
1565    /// forward `From` impl above this variant — `Unknown` is the floor,
1566    /// not the destination.**
1567    Unknown {
1568        debug: String,
1569    },
1570}
1571
1572impl From<LiveAdapterObservation> for WireLiveAdapterObservation {
1573    fn from(value: LiveAdapterObservation) -> Self {
1574        match value {
1575            LiveAdapterObservation::Ready => Self::Ready,
1576            LiveAdapterObservation::UserTranscriptFinal {
1577                provider_item_id,
1578                previous_item_id,
1579                content_index,
1580                text,
1581            } => Self::UserTranscriptFinal {
1582                provider_item_id,
1583                previous_item_id,
1584                content_index,
1585                text,
1586            },
1587            LiveAdapterObservation::AssistantTextDelta {
1588                provider_item_id,
1589                previous_item_id,
1590                content_index,
1591                response_id,
1592                delta_id,
1593                delta,
1594            } => Self::AssistantTextDelta {
1595                provider_item_id,
1596                previous_item_id,
1597                content_index,
1598                response_id,
1599                delta_id,
1600                delta,
1601            },
1602            LiveAdapterObservation::AssistantTranscriptDelta {
1603                provider_item_id,
1604                previous_item_id,
1605                content_index,
1606                response_id,
1607                delta_id,
1608                delta,
1609            } => Self::AssistantTranscriptDelta {
1610                provider_item_id,
1611                previous_item_id,
1612                content_index,
1613                response_id,
1614                delta_id,
1615                delta,
1616            },
1617            LiveAdapterObservation::AssistantAudioChunk {
1618                data,
1619                sample_rate_hz,
1620                channels,
1621                response_id,
1622                item_id,
1623                content_index,
1624            } => {
1625                use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
1626                Self::AssistantAudioChunk {
1627                    data: BASE64_STANDARD.encode(&data),
1628                    sample_rate_hz,
1629                    channels,
1630                    response_id,
1631                    item_id,
1632                    content_index,
1633                }
1634            }
1635            LiveAdapterObservation::AssistantTranscriptFinal {
1636                provider_item_id,
1637                previous_item_id,
1638                content_index,
1639                response_id,
1640                text,
1641                stop_reason,
1642                usage,
1643            } => Self::AssistantTranscriptFinal {
1644                provider_item_id,
1645                previous_item_id,
1646                content_index,
1647                response_id,
1648                text,
1649                stop_reason: WireStopReason::from(stop_reason),
1650                usage: usage.into(),
1651            },
1652            LiveAdapterObservation::AssistantTranscriptTruncated {
1653                provider_item_id,
1654                previous_item_id,
1655                content_index,
1656                response_id,
1657                text,
1658            } => Self::AssistantTranscriptTruncated {
1659                provider_item_id,
1660                previous_item_id,
1661                content_index,
1662                response_id,
1663                text,
1664            },
1665            LiveAdapterObservation::RealtimeTranscript { event } => {
1666                Self::RealtimeTranscript { event }
1667            }
1668            LiveAdapterObservation::ToolCallRequested {
1669                provider_call_id,
1670                tool_name,
1671                arguments,
1672            } => Self::ToolCallRequested {
1673                // #270: the wire mirror carries the provider-native string on
1674                // the wire (this IS the string seam); project the typed core
1675                // newtypes to their `String` payloads at the boundary.
1676                provider_call_id: provider_call_id.0,
1677                tool_name: tool_name.into_string(),
1678                arguments,
1679            },
1680            LiveAdapterObservation::TurnInterrupted { response_id } => {
1681                Self::TurnInterrupted { response_id }
1682            }
1683            LiveAdapterObservation::TurnCompleted {
1684                response_id,
1685                stop_reason,
1686                usage,
1687            } => Self::TurnCompleted {
1688                response_id,
1689                stop_reason: WireStopReason::from(stop_reason),
1690                usage: usage.into(),
1691            },
1692            LiveAdapterObservation::StatusChanged { status } => Self::StatusChanged {
1693                status: status.into(),
1694            },
1695            LiveAdapterObservation::Error { code, message } => Self::Error {
1696                code: code.into(),
1697                message,
1698            },
1699            LiveAdapterObservation::CommandRejected { code, message } => Self::CommandRejected {
1700                code: code.into(),
1701                message,
1702            },
1703            // Core enum is `#[non_exhaustive]`. R3-6 (P2): surface unknown
1704            // variants explicitly via `Unknown { debug }` rather than
1705            // silently coercing to `TurnInterrupted` (the previous default
1706            // — the worst-possible fallback because a real new event would
1707            // surface as a barge-in and drop data downstream). When a new
1708            // core variant lands, add an explicit arm above this comment.
1709            other => {
1710                debug_assert!(
1711                    false,
1712                    "WireLiveAdapterObservation::from saw an unmapped \
1713                     LiveAdapterObservation variant; add an explicit arm in \
1714                     meerkat-contracts/src/wire/live.rs."
1715                );
1716                Self::Unknown {
1717                    debug: format!("{other:?}"),
1718                }
1719            }
1720        }
1721    }
1722}
1723
1724/// Bridge variant: the wire mirror does not duplicate
1725/// [`meerkat_core::live_adapter::LiveAdapterStatus`] inside `Error.code`
1726/// payloads, so the only `From<Wire... > -> Core` path that matters here is
1727/// for the `Error` / `CommandRejected` branch (clients echoing typed errors
1728/// back to the runtime). A full inverse is not required for the SDK
1729/// observation surface — observations flow adapter -> wire -> SDK only —
1730/// but `WireStopReason` and `WireUsage` already provide the inverse via
1731/// their dedicated wire types in `wire/session.rs` and the helper above.
1732///
1733/// (No `impl From<WireLiveAdapterObservation> for LiveAdapterObservation`
1734/// emitted: the wire-side observation is a downstream projection, never the
1735/// authority. Adding one would invite reverse-direction flows that bypass
1736/// the adapter contract; the round-trip *value* equality is asserted via
1737/// JSON in `wire_live_adapter_observation_round_trips_for_all_variants`.)
1738#[cfg(test)]
1739#[allow(clippy::expect_used, clippy::panic)]
1740mod tests {
1741    use super::*;
1742
1743    #[test]
1744    fn live_open_params_round_trip() {
1745        let v = LiveOpenParams {
1746            session_id: "session-1".into(),
1747            turning_mode: None,
1748            transport: None,
1749        };
1750        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1751        // R3-1: omitted `turning_mode` elides on the wire (back-compat).
1752        assert!(
1753            j.get("turning_mode").is_none(),
1754            "default `turning_mode` must elide the field on the wire"
1755        );
1756        let back: LiveOpenParams = serde_json::from_value(j).expect("round-trip should succeed");
1757        assert_eq!(v, back);
1758    }
1759
1760    #[test]
1761    fn live_open_params_explicit_commit_round_trip() {
1762        // R3-1 (P1): explicit-commit on the wire so the G9 typed text-only
1763        // commit_input path is reachable.
1764        let v = LiveOpenParams {
1765            session_id: "session-1".into(),
1766            turning_mode: Some(RealtimeTurningMode::ExplicitCommit),
1767            transport: None,
1768        };
1769        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1770        assert_eq!(j["turning_mode"], "explicit_commit");
1771        let back: LiveOpenParams = serde_json::from_value(j).expect("round-trip should succeed");
1772        assert_eq!(v, back);
1773    }
1774
1775    #[test]
1776    fn live_open_params_provider_managed_explicit_round_trip() {
1777        let v = LiveOpenParams {
1778            session_id: "session-1".into(),
1779            turning_mode: Some(RealtimeTurningMode::ProviderManaged),
1780            transport: None,
1781        };
1782        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1783        assert_eq!(j["turning_mode"], "provider_managed");
1784        let back: LiveOpenParams = serde_json::from_value(j).expect("round-trip should succeed");
1785        assert_eq!(v, back);
1786    }
1787
1788    #[test]
1789    fn live_open_params_webrtc_transport_round_trip() {
1790        let v = LiveOpenParams {
1791            session_id: "session-1".into(),
1792            turning_mode: None,
1793            transport: Some(LiveOpenTransport::Webrtc),
1794        };
1795        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1796        assert_eq!(j["transport"], "webrtc");
1797        let back: LiveOpenParams = serde_json::from_value(j).expect("round-trip should succeed");
1798        assert_eq!(v, back);
1799    }
1800
1801    #[test]
1802    fn live_send_input_params_audio_chunk_round_trip() {
1803        let v = LiveSendInputParams {
1804            channel_id: "live_1".into(),
1805            chunk: LiveInputChunkWire::Audio {
1806                data: "AQID".into(),
1807                sample_rate_hz: 24_000,
1808                channels: 1,
1809            },
1810        };
1811        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1812        let back: LiveSendInputParams =
1813            serde_json::from_value(j).expect("round-trip should succeed");
1814        assert_eq!(v, back);
1815    }
1816
1817    #[test]
1818    fn live_send_input_params_text_chunk_round_trip() {
1819        let v = LiveSendInputParams {
1820            channel_id: "live_1".into(),
1821            chunk: LiveInputChunkWire::Text {
1822                text: "hello".into(),
1823            },
1824        };
1825        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1826        let back: LiveSendInputParams =
1827            serde_json::from_value(j).expect("round-trip should succeed");
1828        assert_eq!(v, back);
1829    }
1830
1831    #[test]
1832    fn live_send_input_params_image_chunk_round_trip() {
1833        // T11: image variant must round-trip via the wire mirror.
1834        let v = LiveSendInputParams {
1835            channel_id: "live_1".into(),
1836            chunk: LiveInputChunkWire::Image {
1837                mime: "image/png".into(),
1838                data: "iVBORw0KGgo=".into(),
1839            },
1840        };
1841        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1842        assert_eq!(j["chunk"]["kind"], "image");
1843        assert_eq!(j["chunk"]["mime"], "image/png");
1844        let back: LiveSendInputParams =
1845            serde_json::from_value(j).expect("round-trip should succeed");
1846        assert_eq!(v, back);
1847    }
1848
1849    #[test]
1850    fn live_send_input_params_video_frame_chunk_round_trip() {
1851        // T11: video-frame variant must round-trip via the wire mirror.
1852        let v = LiveSendInputParams {
1853            channel_id: "live_1".into(),
1854            chunk: LiveInputChunkWire::VideoFrame {
1855                codec: "vp8".into(),
1856                data: "AAECAwQ=".into(),
1857                timestamp_ms: 1_234,
1858            },
1859        };
1860        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1861        assert_eq!(j["chunk"]["kind"], "video_frame");
1862        assert_eq!(j["chunk"]["codec"], "vp8");
1863        assert_eq!(j["chunk"]["timestamp_ms"], 1_234);
1864        let back: LiveSendInputParams =
1865            serde_json::from_value(j).expect("round-trip should succeed");
1866        assert_eq!(v, back);
1867    }
1868
1869    #[test]
1870    fn live_truncate_params_round_trip() {
1871        let v = LiveTruncateParams {
1872            channel_id: "live_1".into(),
1873            item_id: "item_42".into(),
1874            content_index: 0,
1875            audio_played_ms: 1_234,
1876        };
1877        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1878        let back: LiveTruncateParams =
1879            serde_json::from_value(j).expect("round-trip should succeed");
1880        assert_eq!(v, back);
1881    }
1882
1883    // CC5 — typed capabilities mirror.
1884
1885    #[test]
1886    fn wire_live_channel_capabilities_round_trip_serde() {
1887        // Full typed-boolean matrix survives wire round-trip with field
1888        // names visible (no opaque `serde_json::Value` shroud).
1889        let v = WireLiveChannelCapabilities {
1890            audio_in: true,
1891            audio_out: true,
1892            text_in: true,
1893            text_out: true,
1894            image_in: false,
1895            video_in: false,
1896            transcript_supported: true,
1897            barge_in_supported: true,
1898            provider_native_resume: false,
1899        };
1900        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1901        // Each typed boolean is reachable on the wire object — closes the
1902        // CC5 finding that SDK clients had to handcraft access.
1903        assert_eq!(j["audio_in"], true);
1904        assert_eq!(j["audio_out"], true);
1905        assert_eq!(j["text_in"], true);
1906        assert_eq!(j["text_out"], true);
1907        assert_eq!(j["image_in"], false);
1908        assert_eq!(j["video_in"], false);
1909        assert_eq!(j["transcript_supported"], true);
1910        assert_eq!(j["barge_in_supported"], true);
1911        assert_eq!(j["provider_native_resume"], false);
1912        let back: WireLiveChannelCapabilities =
1913            serde_json::from_value(j).expect("round-trip should succeed");
1914        assert_eq!(v, back);
1915    }
1916
1917    #[test]
1918    fn wire_live_channel_capabilities_round_trip_through_core() {
1919        // Core ↔ wire conversion is a value-preserving bijection.
1920        let core = LiveChannelCapabilities {
1921            audio_in: true,
1922            audio_out: true,
1923            text_in: true,
1924            text_out: true,
1925            image_in: true, // `gpt-realtime-2` shape.
1926            video_in: true, // Gemini Live shape.
1927            transcript_supported: true,
1928            barge_in_supported: true,
1929            provider_native_resume: true,
1930        };
1931        let wire: WireLiveChannelCapabilities = core.clone().into();
1932        let back: LiveChannelCapabilities = wire.into();
1933        assert_eq!(core, back);
1934    }
1935
1936    #[test]
1937    fn wire_live_channel_capabilities_anticipates_future_modalities() {
1938        // T8 + CC5 acceptance: the typed matrix represents `gpt-realtime-2`
1939        // (image_in) and Gemini Live (video_in) without provider-specific
1940        // fields.
1941        let gpt_realtime_2 = WireLiveChannelCapabilities {
1942            audio_in: true,
1943            audio_out: true,
1944            text_in: true,
1945            text_out: true,
1946            image_in: true,
1947            video_in: false,
1948            transcript_supported: true,
1949            barge_in_supported: true,
1950            provider_native_resume: false,
1951        };
1952        let gemini_live = WireLiveChannelCapabilities {
1953            audio_in: true,
1954            audio_out: true,
1955            text_in: true,
1956            text_out: true,
1957            image_in: false,
1958            video_in: true,
1959            transcript_supported: true,
1960            barge_in_supported: true,
1961            provider_native_resume: false,
1962        };
1963        let g1 = serde_json::to_value(&gpt_realtime_2).expect("round-trip should succeed");
1964        let g2 = serde_json::to_value(&gemini_live).expect("round-trip should succeed");
1965        assert_eq!(g1["image_in"], true);
1966        assert_eq!(g1["video_in"], false);
1967        assert_eq!(g2["image_in"], false);
1968        assert_eq!(g2["video_in"], true);
1969    }
1970
1971    // CC6 — typed continuity-mode mirror.
1972
1973    #[test]
1974    fn wire_live_continuity_mode_payload_less_variants_round_trip() {
1975        for v in [
1976            WireLiveContinuityMode::Fresh,
1977            WireLiveContinuityMode::TranscriptOnly,
1978            WireLiveContinuityMode::Degraded,
1979        ] {
1980            let j = serde_json::to_value(&v).expect("round-trip should succeed");
1981            // Internally-tagged on `mode` (snake_case) — matches the core
1982            // enum's serde shape exactly.
1983            assert!(j.get("mode").is_some(), "missing `mode` discriminator");
1984            let back: WireLiveContinuityMode =
1985                serde_json::from_value(j).expect("round-trip should succeed");
1986            assert_eq!(v, back);
1987        }
1988    }
1989
1990    #[test]
1991    fn wire_live_continuity_mode_provider_native_resume_round_trip() {
1992        let v = WireLiveContinuityMode::ProviderNativeResume {
1993            provider_session_id: "rtsess_abc123".into(),
1994        };
1995        let j = serde_json::to_value(&v).expect("round-trip should succeed");
1996        // Tagged as `mode: "provider_native_resume"`, payload field
1997        // `provider_session_id` flat alongside the discriminator.
1998        assert_eq!(j["mode"], "provider_native_resume");
1999        assert_eq!(j["provider_session_id"], "rtsess_abc123");
2000        let back: WireLiveContinuityMode =
2001            serde_json::from_value(j).expect("round-trip should succeed");
2002        assert_eq!(v, back);
2003    }
2004
2005    #[test]
2006    fn wire_live_continuity_mode_byte_compatible_with_core() {
2007        // The wire mirror serializes byte-identical to the core enum so
2008        // existing clients keep working across the typed-shape transition.
2009        let core_resume = LiveContinuityMode::ProviderNativeResume {
2010            provider_session_id: "sess_xyz".into(),
2011        };
2012        let wire_resume: WireLiveContinuityMode = core_resume.clone().into();
2013        let core_json = serde_json::to_value(&core_resume).expect("round-trip should succeed");
2014        let wire_json = serde_json::to_value(&wire_resume).expect("round-trip should succeed");
2015        assert_eq!(core_json, wire_json);
2016
2017        let core_transcript = LiveContinuityMode::TranscriptOnly;
2018        let wire_transcript: WireLiveContinuityMode = core_transcript.clone().into();
2019        assert_eq!(
2020            serde_json::to_value(&core_transcript).expect("round-trip should succeed"),
2021            serde_json::to_value(&wire_transcript).expect("round-trip should succeed"),
2022        );
2023    }
2024
2025    #[test]
2026    fn wire_live_continuity_mode_round_trips_through_core() {
2027        for v in [
2028            WireLiveContinuityMode::Fresh,
2029            WireLiveContinuityMode::TranscriptOnly,
2030            WireLiveContinuityMode::Degraded,
2031            WireLiveContinuityMode::ProviderNativeResume {
2032                provider_session_id: "sess_back".into(),
2033            },
2034        ] {
2035            let core: LiveContinuityMode = v
2036                .clone()
2037                .try_into()
2038                .expect("known wire variants must convert to core");
2039            let back: WireLiveContinuityMode = core.into();
2040            assert_eq!(v, back);
2041        }
2042    }
2043
2044    #[test]
2045    fn live_open_result_typed_capabilities_and_continuity_round_trip() {
2046        // The `LiveOpenResult` fields are now typed at the wire boundary —
2047        // SDK codegen sees real shapes, not `serde_json::Value`.
2048        let v = LiveOpenResult {
2049            channel_id: "live_1".into(),
2050            transport: WireLiveTransportBootstrap::Websocket {
2051                url: "wss://example/live".into(),
2052                token: "tok".into(),
2053            },
2054            capabilities: WireLiveChannelCapabilities {
2055                audio_in: true,
2056                audio_out: true,
2057                text_in: true,
2058                text_out: true,
2059                image_in: false,
2060                video_in: false,
2061                transcript_supported: true,
2062                barge_in_supported: true,
2063                provider_native_resume: false,
2064            },
2065            continuity: WireLiveContinuityMode::TranscriptOnly,
2066        };
2067        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2068        // Typed access at the JSON layer too.
2069        assert_eq!(j["capabilities"]["audio_in"], true);
2070        assert_eq!(j["capabilities"]["image_in"], false);
2071        assert_eq!(j["continuity"]["mode"], "transcript_only");
2072        let back: LiveOpenResult = serde_json::from_value(j).expect("round-trip should succeed");
2073        assert_eq!(v, back);
2074    }
2075
2076    // FIX-SDK-OBS — typed `LiveAdapterObservation` wire mirror.
2077
2078    #[test]
2079    fn wire_live_adapter_observation_round_trips_for_all_variants() {
2080        // Each typed variant survives serde round-trip with its
2081        // discriminator and payload visible. Reads exercise the
2082        // R5-4 identity fields on `assistant_audio_chunk` and the R5-9
2083        // `command_rejected` typed error code.
2084
2085        let cases: Vec<WireLiveAdapterObservation> = vec![
2086            WireLiveAdapterObservation::Ready,
2087            WireLiveAdapterObservation::UserTranscriptFinal {
2088                provider_item_id: Some("item_user_1".into()),
2089                previous_item_id: None,
2090                content_index: Some(0),
2091                text: "hello".into(),
2092            },
2093            WireLiveAdapterObservation::AssistantTextDelta {
2094                provider_item_id: Some("item_a".into()),
2095                previous_item_id: None,
2096                content_index: Some(0),
2097                response_id: Some("resp_1".into()),
2098                delta_id: Some("delta_1".into()),
2099                delta: "hi".into(),
2100            },
2101            WireLiveAdapterObservation::AssistantTranscriptDelta {
2102                provider_item_id: Some("item_b".into()),
2103                previous_item_id: None,
2104                content_index: Some(0),
2105                response_id: Some("resp_1".into()),
2106                delta_id: Some("delta_2".into()),
2107                delta: "spoken".into(),
2108            },
2109            // R5-4: identity fields populated on the audio chunk so the
2110            // wire mirror exposes them to SDK clients driving
2111            // `live/truncate`.
2112            WireLiveAdapterObservation::AssistantAudioChunk {
2113                data: "AQID".into(),
2114                sample_rate_hz: 24_000,
2115                channels: 1,
2116                response_id: Some("resp_audio".into()),
2117                item_id: Some("item_audio".into()),
2118                content_index: Some(0),
2119            },
2120            WireLiveAdapterObservation::AssistantTranscriptFinal {
2121                provider_item_id: "item_final".into(),
2122                previous_item_id: None,
2123                content_index: Some(0),
2124                response_id: Some("resp_final".into()),
2125                text: "all done".into(),
2126                stop_reason: WireStopReason::EndTurn,
2127                usage: crate::wire::WireUsage {
2128                    input_tokens: 5,
2129                    output_tokens: 7,
2130                    total_tokens: 12,
2131                    cache_creation_tokens: None,
2132                    cache_read_tokens: None,
2133                },
2134            },
2135            WireLiveAdapterObservation::AssistantTranscriptTruncated {
2136                provider_item_id: Some("item_trunc".into()),
2137                previous_item_id: None,
2138                content_index: Some(0),
2139                response_id: Some("resp_trunc".into()),
2140                text: Some("partial".into()),
2141            },
2142            WireLiveAdapterObservation::ToolCallRequested {
2143                provider_call_id: "call_1".into(),
2144                tool_name: "lookup".into(),
2145                arguments: serde_json::json!({"q": "weather"}),
2146            },
2147            WireLiveAdapterObservation::TurnInterrupted {
2148                response_id: Some("resp_interrupt".into()),
2149            },
2150            WireLiveAdapterObservation::TurnCompleted {
2151                response_id: Some("resp_done".into()),
2152                stop_reason: WireStopReason::EndTurn,
2153                usage: crate::wire::WireUsage {
2154                    input_tokens: 12,
2155                    output_tokens: 34,
2156                    total_tokens: 46,
2157                    cache_creation_tokens: Some(1),
2158                    cache_read_tokens: Some(2),
2159                },
2160            },
2161            WireLiveAdapterObservation::StatusChanged {
2162                status: WireLiveAdapterStatus::Degraded {
2163                    reason: WireLiveDegradationReason::RateLimited,
2164                },
2165            },
2166            WireLiveAdapterObservation::Error {
2167                code: WireLiveAdapterErrorCode::ConnectionLost,
2168                message: "transport gone".into(),
2169            },
2170            // R5-9: typed `CommandRejected` is now a first-class wire variant.
2171            WireLiveAdapterObservation::CommandRejected {
2172                code: WireLiveAdapterErrorCode::ConfigRejected {
2173                    reason: WireLiveConfigRejectionReason::ImageInputNotImplemented,
2174                },
2175                message: "adapter rejected image".into(),
2176            },
2177        ];
2178
2179        for case in cases {
2180            let j = serde_json::to_value(&case).expect("round-trip should succeed");
2181            assert!(
2182                j.get("observation").is_some(),
2183                "missing `observation` discriminator on {case:?}"
2184            );
2185            let back: WireLiveAdapterObservation =
2186                serde_json::from_value(j).expect("round-trip should succeed");
2187            assert_eq!(case, back);
2188        }
2189    }
2190
2191    #[test]
2192    fn wire_live_adapter_observation_assistant_audio_chunk_identity_fields_visible() {
2193        // The R5-4 identity fields (`response_id`, `item_id`,
2194        // `content_index`) appear flat on the JSON object so SDK
2195        // consumers can drive `live/truncate` typed.
2196        let v = WireLiveAdapterObservation::AssistantAudioChunk {
2197            data: "AQID".into(),
2198            sample_rate_hz: 24_000,
2199            channels: 1,
2200            response_id: Some("resp_audio".into()),
2201            item_id: Some("item_audio".into()),
2202            content_index: Some(2),
2203        };
2204        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2205        assert_eq!(j["observation"], "assistant_audio_chunk");
2206        assert_eq!(j["item_id"], "item_audio");
2207        assert_eq!(j["response_id"], "resp_audio");
2208        assert_eq!(j["content_index"], 2);
2209        assert_eq!(j["sample_rate_hz"], 24_000);
2210        assert_eq!(j["channels"], 1);
2211    }
2212
2213    #[test]
2214    fn wire_live_adapter_observation_command_rejected_visible_as_typed_variant() {
2215        let v = WireLiveAdapterObservation::CommandRejected {
2216            code: WireLiveAdapterErrorCode::ConfigRejected {
2217                reason: WireLiveConfigRejectionReason::VideoFrameInputNotImplemented,
2218            },
2219            message: "rejected".into(),
2220        };
2221        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2222        assert_eq!(j["observation"], "command_rejected");
2223        assert_eq!(j["code"]["code"], "config_rejected");
2224        // R5-2: `reason` is the typed enum, internally tagged on `kind`.
2225        assert_eq!(
2226            j["code"]["reason"]["kind"],
2227            "video_frame_input_not_implemented"
2228        );
2229    }
2230
2231    #[test]
2232    fn wire_live_adapter_observation_byte_compatible_with_core_for_audio_chunk() {
2233        // Wire mirror serializes byte-identical to the core enum for the
2234        // R5-4 identity-bearing variant. Catches drift between the two
2235        // shapes the moment a field is added to one and forgotten on the
2236        // other.
2237        let core = LiveAdapterObservation::AssistantAudioChunk {
2238            data: vec![1, 2, 3],
2239            sample_rate_hz: 24_000,
2240            channels: 1,
2241            response_id: Some("resp_audio".into()),
2242            item_id: Some("item_audio".into()),
2243            content_index: Some(2),
2244        };
2245        let wire: WireLiveAdapterObservation = core.clone().into();
2246        let core_json = serde_json::to_value(&core).expect("round-trip should succeed");
2247        let wire_json = serde_json::to_value(&wire).expect("round-trip should succeed");
2248        assert_eq!(core_json, wire_json);
2249    }
2250
2251    #[test]
2252    fn wire_live_adapter_observation_byte_compatible_with_core_for_command_rejected() {
2253        let core = LiveAdapterObservation::CommandRejected {
2254            code: LiveAdapterErrorCode::ConfigRejected {
2255                reason: LiveConfigRejectionReason::ImageInputNotImplemented,
2256            },
2257            message: "rejected".into(),
2258        };
2259        let wire: WireLiveAdapterObservation = core.clone().into();
2260        let core_json = serde_json::to_value(&core).expect("round-trip should succeed");
2261        let wire_json = serde_json::to_value(&wire).expect("round-trip should succeed");
2262        assert_eq!(core_json, wire_json);
2263    }
2264
2265    // R3-6 (P2) — explicit Unknown variant fail-loud regression tests.
2266
2267    #[test]
2268    fn unknown_observation_variant_does_not_become_turn_interrupted() {
2269        // R3-6 (P2): the wire `Unknown` variant must NOT serialize as
2270        // `TurnInterrupted` (the previous fail-open default), and a real
2271        // future-variant forward conversion must NOT collapse onto
2272        // `TurnInterrupted`. Synthesize the future-variant case by
2273        // constructing the wire `Unknown` sentinel directly.
2274        let v = WireLiveAdapterObservation::Unknown {
2275            debug: "FutureVariant { … }".into(),
2276        };
2277        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2278        assert_eq!(
2279            j["observation"], "unknown",
2280            "wire Unknown must NOT serialize as turn_interrupted"
2281        );
2282        assert_ne!(
2283            j["observation"], "turn_interrupted",
2284            "wire Unknown must never coerce to turn_interrupted"
2285        );
2286        assert_eq!(j["debug"], "FutureVariant { … }");
2287        let back: WireLiveAdapterObservation =
2288            serde_json::from_value(j).expect("round-trip should succeed");
2289        assert_eq!(v, back);
2290    }
2291
2292    #[test]
2293    fn known_observation_variants_never_serialize_as_unknown() {
2294        // R3-6 (P2): the explicit-Unknown variant is a floor, not a
2295        // destination. Every known core variant must produce its typed
2296        // wire counterpart, never `Unknown`. Exercises the
2297        // `LiveAdapterObservation::TurnInterrupted` case specifically
2298        // because the previous fail-open default coerced unknown variants
2299        // INTO `TurnInterrupted` — the inverse reachability check confirms
2300        // the new explicit-Unknown path doesn't collide.
2301        let real_interrupt = LiveAdapterObservation::TurnInterrupted {
2302            response_id: Some("resp_real".into()),
2303        };
2304        let wire: WireLiveAdapterObservation = real_interrupt.into();
2305        match &wire {
2306            WireLiveAdapterObservation::TurnInterrupted { response_id } => {
2307                assert_eq!(response_id.as_deref(), Some("resp_real"));
2308            }
2309            other => panic!("real TurnInterrupted must stay TurnInterrupted, got {other:?}"),
2310        }
2311        let j = serde_json::to_value(&wire).expect("round-trip should succeed");
2312        assert_eq!(j["observation"], "turn_interrupted");
2313        assert_ne!(j["observation"], "unknown");
2314    }
2315
2316    // G8 (P2) — typed `LiveTransportBootstrap` wire mirror.
2317
2318    #[test]
2319    fn wire_live_transport_bootstrap_websocket_round_trip() {
2320        let v = WireLiveTransportBootstrap::Websocket {
2321            url: "wss://example/live".into(),
2322            token: "tok_abc".into(),
2323        };
2324        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2325        // Internally-tagged on `transport` (snake_case), payload fields
2326        // flat alongside the discriminator — matches the core enum's serde
2327        // shape exactly.
2328        assert_eq!(j["transport"], "websocket");
2329        assert_eq!(j["url"], "wss://example/live");
2330        assert_eq!(j["token"], "tok_abc");
2331        let back: WireLiveTransportBootstrap =
2332            serde_json::from_value(j).expect("round-trip should succeed");
2333        assert_eq!(v, back);
2334    }
2335
2336    #[test]
2337    fn wire_live_transport_bootstrap_webrtc_round_trip() {
2338        let v = WireLiveTransportBootstrap::Webrtc {
2339            token: "tok_webrtc".into(),
2340            answer_method: "live/webrtc/answer".into(),
2341            http_url: None,
2342        };
2343        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2344        assert_eq!(j["transport"], "webrtc");
2345        assert_eq!(j["token"], "tok_webrtc");
2346        assert_eq!(j["answer_method"], "live/webrtc/answer");
2347        assert!(
2348            j.get("http_url").is_none(),
2349            "missing optional HTTP signaling must elide"
2350        );
2351        let back: WireLiveTransportBootstrap =
2352            serde_json::from_value(j).expect("round-trip should succeed");
2353        assert_eq!(v, back);
2354    }
2355
2356    #[test]
2357    fn wire_live_transport_bootstrap_byte_compatible_with_core() {
2358        // Wire mirror serializes byte-identical to the core enum. Catches
2359        // drift the moment a field is added to one and forgotten on the
2360        // other.
2361        let core = LiveTransportBootstrap::Websocket {
2362            url: "wss://example/live".into(),
2363            token: "tok_xyz".into(),
2364        };
2365        let wire: WireLiveTransportBootstrap = core.clone().into();
2366        let core_json = serde_json::to_value(&core).expect("round-trip should succeed");
2367        let wire_json = serde_json::to_value(&wire).expect("round-trip should succeed");
2368        assert_eq!(core_json, wire_json);
2369    }
2370
2371    #[test]
2372    fn wire_live_transport_bootstrap_webrtc_byte_compatible_with_core() {
2373        let core = LiveTransportBootstrap::Webrtc {
2374            token: "tok_xyz".into(),
2375            answer_method: "live/webrtc/answer".into(),
2376            http_url: Some("https://example/live/webrtc/answer".into()),
2377        };
2378        let wire: WireLiveTransportBootstrap = core.clone().into();
2379        let core_json = serde_json::to_value(&core).expect("round-trip should succeed");
2380        let wire_json = serde_json::to_value(&wire).expect("round-trip should succeed");
2381        assert_eq!(core_json, wire_json);
2382    }
2383
2384    #[test]
2385    fn wire_live_transport_bootstrap_round_trips_through_core() {
2386        let v = WireLiveTransportBootstrap::Websocket {
2387            url: "wss://example/live".into(),
2388            token: "tok_back".into(),
2389        };
2390        // R3-6 (P2): wire → core is now `TryFrom`; `Unknown` rejects.
2391        let core: LiveTransportBootstrap =
2392            LiveTransportBootstrap::try_from(v.clone()).expect("known wire variant should convert");
2393        let back: WireLiveTransportBootstrap = core.into();
2394        assert_eq!(v, back);
2395    }
2396
2397    #[test]
2398    fn wire_live_transport_bootstrap_webrtc_round_trips_through_core() {
2399        let v = WireLiveTransportBootstrap::Webrtc {
2400            token: "tok_back".into(),
2401            answer_method: "live/webrtc/answer".into(),
2402            http_url: None,
2403        };
2404        let core: LiveTransportBootstrap =
2405            LiveTransportBootstrap::try_from(v.clone()).expect("known wire variant should convert");
2406        let back: WireLiveTransportBootstrap = core.into();
2407        assert_eq!(v, back);
2408    }
2409
2410    #[test]
2411    fn live_webrtc_answer_params_and_result_round_trip() {
2412        let params = LiveWebrtcAnswerParams {
2413            channel_id: "ch_1".into(),
2414            token: "tok".into(),
2415            offer_sdp: "v=0\r\n".into(),
2416        };
2417        let params_json = serde_json::to_value(&params).expect("round-trip should succeed");
2418        assert_eq!(params_json["channel_id"], "ch_1");
2419        assert_eq!(params_json["offer_sdp"], "v=0\r\n");
2420        let params_back: LiveWebrtcAnswerParams =
2421            serde_json::from_value(params_json).expect("round-trip should succeed");
2422        assert_eq!(params, params_back);
2423
2424        let result = LiveWebrtcAnswerResult {
2425            answer_sdp: "v=0\r\n".into(),
2426        };
2427        let result_json = serde_json::to_value(&result).expect("round-trip should succeed");
2428        assert_eq!(result_json["answer_sdp"], "v=0\r\n");
2429        let result_back: LiveWebrtcAnswerResult =
2430            serde_json::from_value(result_json).expect("round-trip should succeed");
2431        assert_eq!(result, result_back);
2432    }
2433
2434    #[test]
2435    fn wire_live_transport_bootstrap_unknown_does_not_become_websocket() {
2436        // R3-6 (P2): the wire `Unknown` variant must NOT silently convert
2437        // back to a bogus `Websocket { url: "", token: "" }`. The inverse
2438        // path returns `WireConversionError::Transport` so callers
2439        // explicitly handle the unsupported transport.
2440        let unknown = WireLiveTransportBootstrap::Unknown {
2441            debug: "Webrtc { offer_sdp: \"v=0...\", terminator_url: \"https://example/whip\" }"
2442                .to_string(),
2443        };
2444        match LiveTransportBootstrap::try_from(unknown.clone()) {
2445            Err(WireConversionError::Transport { debug }) => {
2446                assert!(debug.contains("Webrtc"), "debug payload preserved");
2447            }
2448            other => panic!("unknown wire variant must not coerce to a core variant: {other:?}"),
2449        }
2450        // Round-trips through serde as the explicit `unknown` discriminator.
2451        let j = serde_json::to_value(&unknown).expect("round-trip should succeed");
2452        assert_eq!(j["transport"], "unknown");
2453        assert!(
2454            j.get("url").is_none(),
2455            "Unknown wire variant must NOT carry websocket fields — that was the whole bug"
2456        );
2457        let back: WireLiveTransportBootstrap =
2458            serde_json::from_value(j).expect("round-trip should succeed");
2459        assert_eq!(unknown, back);
2460    }
2461
2462    #[test]
2463    fn unknown_transport_variant_round_trips_as_unknown() {
2464        // R3-6 (P2): synthesize the future-variant case by directly
2465        // constructing the wire `Unknown` sentinel. JSON round-trip
2466        // preserves the discriminator and `debug` payload.
2467        let v = WireLiveTransportBootstrap::Unknown {
2468            debug: "FutureVariant { … }".into(),
2469        };
2470        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2471        assert_eq!(j["transport"], "unknown");
2472        assert_eq!(j["debug"], "FutureVariant { … }");
2473        let back: WireLiveTransportBootstrap =
2474            serde_json::from_value(j).expect("round-trip should succeed");
2475        assert_eq!(v, back);
2476    }
2477
2478    // G9 (P2) — typed `LiveResponseModality` wire mirror + commit-input
2479    // params shape.
2480
2481    #[test]
2482    fn wire_live_response_modality_payload_less_variants_round_trip() {
2483        for v in [
2484            WireLiveResponseModality::Audio,
2485            WireLiveResponseModality::Text,
2486        ] {
2487            let j = serde_json::to_value(&v).expect("round-trip should succeed");
2488            assert!(
2489                j.get("modality").is_some(),
2490                "missing `modality` discriminator"
2491            );
2492            let back: WireLiveResponseModality =
2493                serde_json::from_value(j).expect("round-trip should succeed");
2494            assert_eq!(v, back);
2495        }
2496    }
2497
2498    #[test]
2499    fn wire_live_response_modality_byte_compatible_with_core() {
2500        for core in [LiveResponseModality::Audio, LiveResponseModality::Text] {
2501            let wire: WireLiveResponseModality = core.into();
2502            let core_json = serde_json::to_value(core).expect("round-trip should succeed");
2503            let wire_json = serde_json::to_value(&wire).expect("round-trip should succeed");
2504            assert_eq!(core_json, wire_json);
2505        }
2506    }
2507
2508    #[test]
2509    fn live_commit_input_params_default_modality_round_trip() {
2510        // Omitted `response_modality` keeps the channel default — the JSON
2511        // payload elides the field entirely (skip_serializing_if).
2512        let v = LiveCommitInputParams {
2513            channel_id: "live_1".into(),
2514            response_modality: None,
2515        };
2516        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2517        assert_eq!(j["channel_id"], "live_1");
2518        assert!(
2519            j.get("response_modality").is_none(),
2520            "default-modality params must elide the field"
2521        );
2522        let back: LiveCommitInputParams =
2523            serde_json::from_value(j).expect("round-trip should succeed");
2524        assert_eq!(v, back);
2525    }
2526
2527    #[test]
2528    fn live_commit_input_params_text_modality_round_trip() {
2529        let v = LiveCommitInputParams {
2530            channel_id: "live_1".into(),
2531            response_modality: Some(WireLiveResponseModality::Text),
2532        };
2533        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2534        assert_eq!(j["channel_id"], "live_1");
2535        assert_eq!(j["response_modality"]["modality"], "text");
2536        let back: LiveCommitInputParams =
2537            serde_json::from_value(j).expect("round-trip should succeed");
2538        assert_eq!(v, back);
2539    }
2540
2541    /// R4-5 (P3): the typed `LiveRefreshResult` round-trips through JSON on
2542    /// the typed `status` discriminator alone. The `status: queued` shape
2543    /// mirrors the generated authority result after host queue acceptance;
2544    /// the deleted legacy `refresh_enqueued` boolean must not reappear.
2545    #[test]
2546    fn live_refresh_result_queued_round_trip() {
2547        let v = LiveRefreshResult::queued();
2548        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2549        assert_eq!(j["status"], "queued");
2550        assert!(
2551            j.get("refresh_enqueued").is_none(),
2552            "deleted legacy `refresh_enqueued` boolean must not be on the wire"
2553        );
2554        let back: LiveRefreshResult = serde_json::from_value(j).expect("round-trip should succeed");
2555        assert_eq!(v, back);
2556    }
2557
2558    #[test]
2559    fn live_close_result_closed_round_trip() {
2560        let v = LiveCloseResult::closed();
2561        let j = serde_json::to_value(&v).expect("round-trip should succeed");
2562        assert_eq!(j["status"], "closed");
2563        assert!(
2564            j.get("closed").is_none(),
2565            "deleted legacy `closed` boolean must not be on the wire"
2566        );
2567        let back: LiveCloseResult = serde_json::from_value(j).expect("round-trip should succeed");
2568        assert_eq!(v, back);
2569    }
2570
2571    // R5-3 (P3) — explicit Unknown variant fail-loud regression tests for
2572    // continuity / response_modality / status / error_code wire mirrors.
2573
2574    #[test]
2575    fn unknown_continuity_does_not_become_fresh() {
2576        // R5-3 (P3): the wire `Unknown` variant must NOT convert back to
2577        // `Fresh` (the previous fail-open default). The inverse path
2578        // returns `WireConversionError::Continuity` so callers
2579        // route on the typed error rather than silently fabricating a
2580        // fresh-channel placeholder.
2581        let unknown = WireLiveContinuityMode::Unknown {
2582            debug: "FutureContinuity { … }".into(),
2583        };
2584        match LiveContinuityMode::try_from(unknown.clone()) {
2585            Err(WireConversionError::Continuity { debug }) => {
2586                assert!(
2587                    debug.contains("FutureContinuity"),
2588                    "debug payload preserved"
2589                );
2590            }
2591            other => panic!("unknown wire variant must not coerce to a core variant: {other:?}"),
2592        }
2593        // Round-trips through serde as the explicit `unknown` discriminator.
2594        let j = serde_json::to_value(&unknown).expect("round-trip should succeed");
2595        assert_eq!(j["mode"], "unknown");
2596        assert_ne!(j["mode"], "fresh", "Unknown must never serialize as fresh");
2597        assert!(
2598            j.get("provider_session_id").is_none(),
2599            "Unknown wire variant must NOT carry resume fields"
2600        );
2601        let back: WireLiveContinuityMode =
2602            serde_json::from_value(j).expect("round-trip should succeed");
2603        assert_eq!(unknown, back);
2604    }
2605
2606    #[test]
2607    fn unknown_response_modality_does_not_become_audio() {
2608        // R5-3 (P3): the wire `Unknown` variant must NOT convert back to
2609        // `Audio` (the previous fail-open default that would route a
2610        // future text/structured response through the audio playback path
2611        // and drop content).
2612        let unknown = WireLiveResponseModality::Unknown {
2613            debug: "Structured { … }".into(),
2614        };
2615        match LiveResponseModality::try_from(unknown.clone()) {
2616            Err(WireConversionError::ResponseModality { debug }) => {
2617                assert!(debug.contains("Structured"), "debug payload preserved");
2618            }
2619            other => panic!("unknown wire variant must not coerce to a core variant: {other:?}"),
2620        }
2621        let j = serde_json::to_value(&unknown).expect("round-trip should succeed");
2622        assert_eq!(j["modality"], "unknown");
2623        assert_ne!(
2624            j["modality"], "audio",
2625            "Unknown must never serialize as audio"
2626        );
2627        let back: WireLiveResponseModality =
2628            serde_json::from_value(j).expect("round-trip should succeed");
2629        assert_eq!(unknown, back);
2630    }
2631
2632    #[test]
2633    fn unknown_status_does_not_become_closed() {
2634        // R5-3 (P3): the wire `Unknown` variant must NOT serialize as
2635        // `closed` (the previous fail-open default — a plausible lie that
2636        // would tell consumers a healthy channel was torn down). No
2637        // inverse `From/TryFrom` is emitted today (matches the
2638        // `WireLiveAdapterObservation` precedent — wire-side status is a
2639        // downstream projection, never authority); the regression
2640        // assertion lives at the serialization layer.
2641        let unknown = WireLiveAdapterStatus::Unknown {
2642            debug: "Reconnecting { … }".into(),
2643        };
2644        let j = serde_json::to_value(&unknown).expect("round-trip should succeed");
2645        assert_eq!(j["status"], "unknown");
2646        assert_ne!(
2647            j["status"], "closed",
2648            "Unknown must never serialize as closed"
2649        );
2650        assert_eq!(j["debug"], "Reconnecting { … }");
2651        let back: WireLiveAdapterStatus =
2652            serde_json::from_value(j).expect("round-trip should succeed");
2653        assert_eq!(unknown, back);
2654    }
2655
2656    #[test]
2657    fn unknown_error_code_does_not_become_internal_error() {
2658        // R5-3 (P3): the wire `Unknown` variant must NOT convert back to
2659        // `InternalError` (the previous fail-open default that would
2660        // attribute every future-variant failure to an internal-server
2661        // bug and mask real provider/transport classification).
2662        let unknown = WireLiveAdapterErrorCode::Unknown {
2663            debug: "QuotaExhausted { … }".into(),
2664        };
2665        match LiveAdapterErrorCode::try_from(unknown.clone()) {
2666            Err(WireConversionError::ErrorCode { debug }) => {
2667                assert!(debug.contains("QuotaExhausted"), "debug payload preserved");
2668            }
2669            other => panic!("unknown wire variant must not coerce to a core variant: {other:?}"),
2670        }
2671        let j = serde_json::to_value(&unknown).expect("round-trip should succeed");
2672        assert_eq!(j["code"], "unknown");
2673        assert_ne!(
2674            j["code"], "internal_error",
2675            "Unknown must never serialize as internal_error"
2676        );
2677        let back: WireLiveAdapterErrorCode =
2678            serde_json::from_value(j).expect("round-trip should succeed");
2679        assert_eq!(unknown, back);
2680    }
2681
2682    // R6-5 (P3 dogma) — explicit Unknown variant fail-loud regression tests
2683    // for the WireLiveConfigRejectionReason mirror.
2684
2685    #[test]
2686    fn unknown_config_rejection_reason_does_not_become_other() {
2687        // R6-5 (P3): the wire `Unknown` variant must NOT convert back to
2688        // `Other { detail: "..." }` (the previous fail-open default — a
2689        // "typed route becomes detail string" antipattern that forced SDK
2690        // consumers to parse English from `detail` to recover the missing
2691        // route).
2692        let unknown = WireLiveConfigRejectionReason::Unknown {
2693            debug: "FuturePolicyRejection { … }".into(),
2694        };
2695        match LiveConfigRejectionReason::try_from(unknown.clone()) {
2696            Err(WireConversionError::ConfigRejectionReason { debug }) => {
2697                assert!(
2698                    debug.contains("FuturePolicyRejection"),
2699                    "debug payload preserved"
2700                );
2701            }
2702            other => panic!("unknown wire variant must not coerce to a core variant: {other:?}"),
2703        }
2704        // Round-trips through serde as the explicit `unknown` discriminator.
2705        let j = serde_json::to_value(&unknown).expect("round-trip should succeed");
2706        assert_eq!(j["kind"], "unknown");
2707        assert_ne!(
2708            j["kind"], "other",
2709            "Unknown must never serialize as `other`"
2710        );
2711        assert!(
2712            j.get("detail").is_none(),
2713            "Unknown wire variant must NOT carry an `Other.detail` field"
2714        );
2715        assert_eq!(j["debug"], "FuturePolicyRejection { … }");
2716        let back: WireLiveConfigRejectionReason =
2717            serde_json::from_value(j).expect("round-trip should succeed");
2718        assert_eq!(unknown, back);
2719    }
2720
2721    #[test]
2722    fn known_config_rejection_reason_variants_never_serialize_as_unknown() {
2723        // R6-5 (P3): the explicit-Unknown variant is a floor, not a
2724        // destination. Every known core variant must produce its typed
2725        // wire counterpart, never `Unknown`.
2726        let real_other = LiveConfigRejectionReason::Other {
2727            detail: "real explanation".into(),
2728        };
2729        let wire: WireLiveConfigRejectionReason = real_other.into();
2730        match &wire {
2731            WireLiveConfigRejectionReason::Other { detail } => {
2732                assert_eq!(detail, "real explanation");
2733            }
2734            other => panic!("real Other must stay Other, got {other:?}"),
2735        }
2736        let j = serde_json::to_value(&wire).expect("round-trip should succeed");
2737        assert_eq!(j["kind"], "other");
2738        assert_ne!(j["kind"], "unknown");
2739    }
2740
2741    #[test]
2742    fn config_rejection_reason_round_trips_through_core() {
2743        // R6-5 (P3): every known wire variant must round-trip through
2744        // core via the `TryFrom` inverse.
2745        let cases = [
2746            WireLiveConfigRejectionReason::ImageInputNotImplemented,
2747            WireLiveConfigRejectionReason::VideoFrameInputNotImplemented,
2748            WireLiveConfigRejectionReason::UnsupportedInputChunkVariant,
2749            WireLiveConfigRejectionReason::NonRealtimeResolution {
2750                detail: "not realtime".into(),
2751            },
2752            WireLiveConfigRejectionReason::RefreshModelSwap {
2753                from_model: "a".into(),
2754                to_model: "b".into(),
2755            },
2756            WireLiveConfigRejectionReason::AudioInputFormatMismatch {
2757                expected_sample_rate_hz: 24_000,
2758                expected_channels: 1,
2759                actual_sample_rate_hz: 16_000,
2760                actual_channels: 2,
2761            },
2762            WireLiveConfigRejectionReason::ChannelIdentitySwap {
2763                from_model: "claude-opus-4-8".into(),
2764                from_provider: WireProvider::Anthropic,
2765                to_model: "gpt-5.4".into(),
2766                to_provider: WireProvider::OpenAi,
2767                auth_binding_changed: false,
2768            },
2769            WireLiveConfigRejectionReason::Other {
2770                detail: "anything".into(),
2771            },
2772        ];
2773        for v in cases {
2774            let core: LiveConfigRejectionReason = v
2775                .clone()
2776                .try_into()
2777                .expect("known wire variant should convert");
2778            let back: WireLiveConfigRejectionReason = core.into();
2779            assert_eq!(v, back);
2780        }
2781    }
2782
2783    // --- WireProvider regression tests ---
2784
2785    #[test]
2786    fn wire_provider_openai_serializes_as_openai() {
2787        // P2 regression: core `Provider::OpenAI` with `rename_all = "snake_case"`
2788        // serializes as `"open_a_i"`. `WireProvider::OpenAi` must serialize as
2789        // `"openai"` on the wire.
2790        let v = WireProvider::OpenAi;
2791        let j = serde_json::to_value(&v).expect("serialization should succeed");
2792        assert_eq!(
2793            j, "openai",
2794            "WireProvider::OpenAi must serialize as \"openai\", not \"open_a_i\""
2795        );
2796    }
2797
2798    #[test]
2799    fn wire_provider_all_known_variants_round_trip() {
2800        let cases = [
2801            (WireProvider::Anthropic, "anthropic"),
2802            (WireProvider::OpenAi, "openai"),
2803            (WireProvider::Gemini, "gemini"),
2804            (WireProvider::SelfHosted, "self_hosted"),
2805            (WireProvider::Other, "other"),
2806        ];
2807        for (variant, expected_str) in cases {
2808            let j = serde_json::to_value(&variant).expect("serialization should succeed");
2809            assert_eq!(j, expected_str, "variant {variant:?} wrong wire name");
2810            let back: WireProvider =
2811                serde_json::from_value(j).expect("deserialization should succeed");
2812            assert_eq!(variant, back);
2813        }
2814    }
2815
2816    #[test]
2817    fn wire_provider_from_core_round_trips() {
2818        let cases = [
2819            (Provider::Anthropic, WireProvider::Anthropic),
2820            (Provider::OpenAI, WireProvider::OpenAi),
2821            (Provider::Gemini, WireProvider::Gemini),
2822            (Provider::SelfHosted, WireProvider::SelfHosted),
2823            (Provider::Other, WireProvider::Other),
2824        ];
2825        for (core, expected_wire) in cases {
2826            let wire: WireProvider = core.into();
2827            assert_eq!(wire, expected_wire);
2828            let back: Provider = wire.try_into().expect("known wire variant should convert");
2829            assert_eq!(back, core);
2830        }
2831    }
2832
2833    #[test]
2834    fn wire_provider_unknown_does_not_become_known_variant() {
2835        let unknown = WireProvider::Unknown;
2836        let j = serde_json::to_value(&unknown).expect("serialization should succeed");
2837        assert_eq!(
2838            j, "unknown",
2839            "WireProvider::Unknown must serialize as \"unknown\""
2840        );
2841        let back: WireProvider = serde_json::from_value(j).expect("deserialization should succeed");
2842        assert_eq!(unknown, back);
2843        match Provider::try_from(unknown) {
2844            Err(WireConversionError::Provider { .. }) => {
2845                // Expected: Unknown has no core counterpart.
2846            }
2847            other => panic!("unknown wire variant must not coerce to a core variant: {other:?}"),
2848        }
2849    }
2850
2851    #[test]
2852    fn channel_identity_swap_serializes_provider_correctly() {
2853        // P2 regression: the old shape exposed core `Provider` which
2854        // serialized `OpenAI` as `"open_a_i"`. The new `WireProvider`
2855        // must serialize as `"openai"`.
2856        let v = WireLiveConfigRejectionReason::ChannelIdentitySwap {
2857            from_model: "claude-opus-4-8".into(),
2858            from_provider: WireProvider::Anthropic,
2859            to_model: "gpt-5.4".into(),
2860            to_provider: WireProvider::OpenAi,
2861            auth_binding_changed: false,
2862        };
2863        let j = serde_json::to_value(&v).expect("serialization should succeed");
2864        assert_eq!(j["from_provider"], "anthropic");
2865        assert_eq!(j["to_provider"], "openai");
2866        assert!(j.get("auth_binding_changed").is_none());
2867        // Must NOT serialize as "open_a_i"
2868        assert_ne!(
2869            j["to_provider"], "open_a_i",
2870            "WireProvider must use explicit rename, not snake_case"
2871        );
2872    }
2873}