Skip to main content

meerkat_live/
host.rs

1//! Live adapter host — transport-side orchestrator for live provider sessions.
2//!
3//! E26: relocated from `meerkat-runtime` into `meerkat-live` so the dependency
4//! direction matches the architectural intent — transport (`meerkat-live`)
5//! owns the live-adapter seam directly and surfaces (`meerkat-rpc`) compose
6//! the host with a `SessionService`-backed `LiveProjectionSink`. Previously
7//! `meerkat-live` depended on `meerkat-runtime` to import the host, which
8//! was the wrong direction (the runtime crate is heavyweight and shouldn't
9//! be a transitive dep of a thin transport crate).
10//!
11//! Sits outside MeerkatMachine (not machine state, not a second machine).
12//! Uses Meerkat services to build projections and gate observations.
13//! Provider transport mechanics stay inside adapter implementations.
14//!
15//! ## Projection contract (wave-2 MVP)
16//!
17//! The host owns the seam between adapter observations and canonical Meerkat
18//! semantic facts. `apply_observation` dispatches by [`ObservationRouting`]:
19//!
20//! - `AppendTranscript`  → writes to the injected [`LiveProjectionSink`]
21//!   (user transcripts, assistant deltas/finals, turn-completed projection).
22//! - `DispatchToolCall`  → routes through the injected
23//!   [`LiveToolDispatcher`] and submits the result back via
24//!   [`LiveAdapterCommand::SubmitToolResult`] / `SubmitToolError`.
25//! - `SignalInterrupt`   → calls the sink's `signal_turn_interrupt` (the same
26//!   path the user-facing interrupt RPC uses).
27//! - `UpdateStatus`      → reports status already committed through generated
28//!   MeerkatMachine status authority.
29//! - `TerminalError`     → surfaces the error to the sink only after
30//!   generated close authority has committed channel terminality.
31//! - `Noop`              → no-op (e.g. bare audio chunks in the projection).
32//!
33//! `LiveProjectionSink` is the runtime-side abstraction over `SessionService`.
34//! Wiring an implementation through `SessionService` is the surface side's
35//! responsibility — the host trait is intentionally minimal so the same host
36//! can be exercised in deterministic unit tests with a recorder fake.
37
38use std::collections::HashMap;
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::time::Duration;
42
43use indexmap::IndexMap;
44use meerkat_core::ToolCallId;
45use meerkat_core::ToolDispatchOutcome;
46use meerkat_core::ToolError;
47use meerkat_core::live_adapter::{
48    LiveAdapter, LiveAdapterCommand, LiveAdapterError, LiveAdapterErrorCode,
49    LiveAdapterObservation, LiveAdapterStatus, LiveInputChunk, LiveToolResult,
50};
51use meerkat_core::types::{SessionId, StopReason, ToolCall, ToolName, ToolResult, Usage};
52use meerkat_core::{
53    RealtimeTranscriptApplyOutcome, RealtimeTranscriptEvent, RealtimeUserContentApplyOutcome,
54};
55use tokio::sync::Mutex;
56
57/// Opaque channel identifier for a live adapter session.
58///
59/// G41: contents are a v4 UUID minted at `LiveAdapterHost::open_channel` time
60/// (via [`Self::random_uuid`]). The previous `live_{N}` `AtomicU64` shape
61/// collided across `rkat-rpc` restarts and across instances sharing a host.
62/// The newtype wraps `String` (not `Uuid`) so external callers — handlers
63/// that round-trip the value through wire types and CLI surfaces that already
64/// store opaque channel ids as strings — do not need a dependency on `uuid`.
65/// `Self::new` is preserved for callers that already hold a serialized id
66/// (test setup, deserialized wire frames).
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub struct LiveChannelId(String);
69
70impl LiveChannelId {
71    #[must_use]
72    pub fn new(id: impl Into<String>) -> Self {
73        Self(id.into())
74    }
75
76    /// Mint a fresh, globally-unique channel id (v4 UUID, hyphenated).
77    ///
78    /// G41: replaces the prior process-monotonic `live_{N}` shape so that
79    /// channel ids are durable identifiers rather than process-local
80    /// infrastructure handles.
81    #[must_use]
82    pub fn random_uuid() -> Self {
83        Self(uuid::Uuid::new_v4().to_string())
84    }
85
86    #[must_use]
87    pub fn as_str(&self) -> &str {
88        &self.0
89    }
90}
91
92/// Typed evidence that a live refresh command was accepted onto the adapter
93/// command queue.
94///
95/// This is host-minted observation evidence, not the public refresh result.
96/// External crates can read it and submit it to MeerkatMachine, but cannot
97/// forge it because the constructor is private to `meerkat-live`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct LiveRefreshQueueAcceptance {
100    channel_id: String,
101    acceptance_sequence: u64,
102}
103
104impl LiveRefreshQueueAcceptance {
105    #[must_use]
106    pub fn channel_id(&self) -> &str {
107        &self.channel_id
108    }
109
110    #[must_use]
111    pub fn acceptance_sequence(&self) -> u64 {
112        self.acceptance_sequence
113    }
114
115    fn from_host_queue_acceptance(
116        channel_id: impl Into<String>,
117        acceptance_sequence: u64,
118    ) -> Option<Self> {
119        let channel_id = channel_id.into();
120        if channel_id.is_empty() || acceptance_sequence == 0 {
121            return None;
122        }
123        Some(Self {
124            channel_id,
125            acceptance_sequence,
126        })
127    }
128}
129
130/// Closed classifier for live adapter commands whose queue acceptance backs a
131/// public RPC result.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
133pub enum LiveCommandAcceptanceKind {
134    SendInput,
135    CommitInput,
136    Interrupt,
137    TruncateAssistantOutput,
138}
139
140/// Typed evidence that a live adapter command was accepted onto the adapter
141/// command queue.
142///
143/// This is host-minted observation evidence, not the public RPC result.
144/// External crates can read it and submit it to MeerkatMachine, but cannot
145/// forge it because the constructor is private to `meerkat-live`.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct LiveCommandQueueAcceptance {
148    channel_id: String,
149    kind: LiveCommandAcceptanceKind,
150    acceptance_sequence: u64,
151}
152
153impl LiveCommandQueueAcceptance {
154    #[must_use]
155    pub fn channel_id(&self) -> &str {
156        &self.channel_id
157    }
158
159    #[must_use]
160    pub fn kind(&self) -> LiveCommandAcceptanceKind {
161        self.kind
162    }
163
164    #[must_use]
165    pub fn acceptance_sequence(&self) -> u64 {
166        self.acceptance_sequence
167    }
168
169    fn from_host_queue_acceptance(
170        channel_id: impl Into<String>,
171        kind: LiveCommandAcceptanceKind,
172        acceptance_sequence: u64,
173    ) -> Option<Self> {
174        let channel_id = channel_id.into();
175        if channel_id.is_empty() || acceptance_sequence == 0 {
176            return None;
177        }
178        Some(Self {
179            channel_id,
180            kind,
181            acceptance_sequence,
182        })
183    }
184}
185
186/// Typed evidence that a live channel close handoff was accepted by the host.
187///
188/// This is host-minted observation evidence, not the public close result.
189/// External crates can read it and submit it to MeerkatMachine, but cannot
190/// forge it because the constructor is private to `meerkat-live`.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct LiveChannelCloseObservation {
193    channel_id: String,
194    close_sequence: u64,
195}
196
197impl LiveChannelCloseObservation {
198    #[must_use]
199    pub fn channel_id(&self) -> &str {
200        &self.channel_id
201    }
202
203    #[must_use]
204    pub fn close_sequence(&self) -> u64 {
205        self.close_sequence
206    }
207
208    fn from_host_close_observation(
209        channel_id: impl Into<String>,
210        close_sequence: u64,
211    ) -> Option<Self> {
212        let channel_id = channel_id.into();
213        if channel_id.is_empty() || close_sequence == 0 {
214            return None;
215        }
216        Some(Self {
217            channel_id,
218            close_sequence,
219        })
220    }
221}
222
223/// Non-forgeable generated-authority handoff for committing host close cleanup.
224#[derive(Debug, Clone)]
225pub struct LiveChannelCloseCommitAuthority {
226    channel_id: String,
227    close_sequence: u64,
228    consumed: Arc<AtomicBool>,
229}
230
231impl LiveChannelCloseCommitAuthority {
232    #[must_use]
233    pub fn channel_id(&self) -> &str {
234        &self.channel_id
235    }
236
237    #[must_use]
238    pub fn close_sequence(&self) -> u64 {
239        self.close_sequence
240    }
241
242    fn consume_once(&self) -> Result<(), LiveAdapterHostError> {
243        self.consumed
244            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
245            .map(|_| ())
246            .map_err(|_| LiveAdapterHostError::CloseAuthorityAlreadyConsumed)
247    }
248
249    #[cfg_attr(not(meerkat_internal_generated_authority_bridge), allow(dead_code))]
250    fn from_generated_parts(channel_id: String, close_sequence: u64) -> Self {
251        Self {
252            channel_id,
253            close_sequence,
254            consumed: Arc::new(AtomicBool::new(false)),
255        }
256    }
257
258    #[cfg(test)]
259    fn from_generated_test_machine(
260        session_id: &SessionId,
261        channel_id: &LiveChannelId,
262        close_sequence: u64,
263    ) -> Self {
264        let mut authority = generated_test_machine_for_registered_session(session_id);
265        let channel_id_string = channel_id.as_str().to_owned();
266        meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
267            &mut authority,
268            meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::ResolveLiveOpenAdmission {
269                session_id: session_id.to_string(),
270                channel_id: channel_id_string.clone(),
271                llm_identity: generated_test_llm_identity(),
272            },
273        )
274        .expect("generated MeerkatMachine live-open admission");
275        let transition = meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
276            &mut authority,
277            meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::RecordLiveCloseClosed {
278                session_id: session_id.to_string(),
279                channel_id: channel_id_string.clone(),
280                close_observation_sequence: close_sequence,
281            },
282        )
283        .expect("generated MeerkatMachine live-close result");
284        assert!(
285            transition.effects().iter().any(|effect| matches!(
286                effect,
287                meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineEffect::LiveCloseResultResolved {
288                    channel_id: effect_channel_id,
289                    close_observation_sequence,
290                    ..
291                } if *effect_channel_id == channel_id_string && *close_observation_sequence == close_sequence
292            )),
293            "generated live-close result effect"
294        );
295        Self::from_generated_parts(channel_id_string, close_sequence)
296    }
297}
298
299/// Host-minted observation of the adapter transport status.
300///
301/// The host owns transport mechanics and observed adapter health; generated
302/// MeerkatMachine authority owns the public `live/status` result projected to
303/// SDK/RPC callers. `observation_sequence` is a provenance nonce for
304/// correlating a shell observation with the generated effect that accepted it;
305/// skipped or rejected nonce values are not semantic status truth.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct LiveChannelStatusObservation {
308    channel_id: String,
309    status: LiveAdapterStatus,
310    observation_sequence: u64,
311}
312
313impl LiveChannelStatusObservation {
314    #[must_use]
315    pub fn channel_id(&self) -> &str {
316        &self.channel_id
317    }
318
319    #[must_use]
320    pub fn status(&self) -> &LiveAdapterStatus {
321        &self.status
322    }
323
324    #[must_use]
325    pub fn observation_sequence(&self) -> u64 {
326        self.observation_sequence
327    }
328
329    fn from_host_status_observation(
330        channel_id: impl Into<String>,
331        status: LiveAdapterStatus,
332        observation_sequence: u64,
333    ) -> Option<Self> {
334        let channel_id = channel_id.into();
335        if channel_id.is_empty() || observation_sequence == 0 {
336            return None;
337        }
338        Some(Self {
339            channel_id,
340            status,
341            observation_sequence,
342        })
343    }
344}
345
346/// Non-forgeable generated-authority handoff for committing host status cache.
347#[derive(Debug, Clone)]
348pub struct LiveChannelStatusCommitAuthority {
349    channel_id: String,
350    status_observation_sequence: u64,
351    consumed: Arc<AtomicBool>,
352}
353
354impl LiveChannelStatusCommitAuthority {
355    #[must_use]
356    pub fn channel_id(&self) -> &str {
357        &self.channel_id
358    }
359
360    #[must_use]
361    pub fn status_observation_sequence(&self) -> u64 {
362        self.status_observation_sequence
363    }
364
365    fn consume_once(&self) -> Result<(), LiveAdapterHostError> {
366        self.consumed
367            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
368            .map(|_| ())
369            .map_err(|_| LiveAdapterHostError::StatusAuthorityAlreadyConsumed)
370    }
371
372    #[cfg_attr(not(meerkat_internal_generated_authority_bridge), allow(dead_code))]
373    fn from_generated_parts(channel_id: String, status_observation_sequence: u64) -> Self {
374        Self {
375            channel_id,
376            status_observation_sequence,
377            consumed: Arc::new(AtomicBool::new(false)),
378        }
379    }
380}
381
382impl std::fmt::Display for LiveChannelId {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        f.write_str(&self.0)
385    }
386}
387
388// ---------------------------------------------------------------------------
389// Projection sink — the host's escape hatch into canonical Meerkat semantics
390// ---------------------------------------------------------------------------
391
392/// Routed outcome of [`LiveAdapterHost::apply_observation`].
393///
394/// Returned to callers (e.g. the WS pump in `meerkat-rpc`) so they can both
395/// observe what semantic action was taken and short-circuit follow-up work
396/// (for instance, drop the channel after a `Terminal` outcome).
397#[derive(Debug, Clone, PartialEq)]
398#[non_exhaustive]
399pub enum ObservationOutcome {
400    /// Observation was a no-op at the projection level (audio chunk, idle event).
401    Noop,
402    /// Status was updated; new value is reported.
403    StatusUpdated(LiveAdapterStatus),
404    /// Transcript fragment was appended to canonical session history.
405    TranscriptAppended,
406    /// Assistant transcript was truncated (barge-in projection).
407    TranscriptTruncated,
408    /// A tool call was dispatched and its result was submitted back to the adapter.
409    ToolCallDispatched {
410        provider_call_id: String,
411        tool_name: String,
412    },
413    /// A tool call was observed but no dispatcher was wired; nothing was sent back.
414    ToolCallSkipped {
415        provider_call_id: String,
416        tool_name: String,
417        reason: ToolDispatchSkipReason,
418    },
419    /// A tool call was dispatched but did not produce a result within the
420    /// configured timeout. The adapter has been notified via
421    /// `LiveAdapterCommand::SubmitToolError` so the provider can unblock its
422    /// turn; no `ToolDispatched` outcome is projected (dogma sin #3 — the
423    /// runtime decides the semantic consequence of a stuck tool).
424    ToolCallTimedOut {
425        provider_call_id: String,
426        tool_name: String,
427        timeout: std::time::Duration,
428    },
429    /// Turn-interrupt signal was forwarded to the projection sink.
430    InterruptSignalled,
431    /// Channel was terminalized (terminal error or `Closed`).
432    Terminal { code: LiveAdapterErrorCode },
433    /// R5-9: a per-command failure scoped to the offending command —
434    /// e.g. an unsupported `LiveInputChunk::Image` rejected by the
435    /// provider's local guard. The channel survives; the WS pump
436    /// forwards the typed JSON observation to the client and continues
437    /// the loop. Sibling of [`Self::Terminal`] for the typed
438    /// `LiveAdapterObservation::CommandRejected` variant.
439    CommandRejected {
440        code: LiveAdapterErrorCode,
441        message: String,
442    },
443    /// Public receipt synthesized from the reducer result only after the
444    /// projection sink has durably committed canonical user content.
445    UserContentCommitted { observation: LiveAdapterObservation },
446}
447
448/// Why a tool call observation was not dispatched. Distinguishes "no
449/// dispatcher injected" (config gap) from "dispatcher rejected the call"
450/// (downstream failure projected as a tool-error result).
451#[derive(Debug, Clone, PartialEq, Eq)]
452#[non_exhaustive]
453pub enum ToolDispatchSkipReason {
454    NoDispatcher,
455    InvalidArguments,
456}
457
458/// Typed terminal fact for a live tool dispatch that exceeded the configured
459/// per-call timeout (D281).
460///
461/// The host already returns the typed [`ObservationOutcome::ToolCallTimedOut`]
462/// to its own callers; this newtype is the *adapter-facing* counterpart. The
463/// previous code fabricated an ad-hoc parseable string
464/// (`format!("tool dispatch timeout after {timeout:?}")`) and submitted it as
465/// the [`LiveAdapterCommand::SubmitToolError`] payload, asking the adapter /
466/// downstream to recover the fact by parsing prose. Instead the host now mints
467/// this typed fact; the only point a string is produced is its [`Display`]
468/// projection at the meerkat-core `SubmitToolError { error: String }` seam
469/// edge (that wire field is owned by `meerkat-core::live_adapter`, so the
470/// adapter command itself cannot be made fully typed from this crate). The
471/// `Display` rendering is a deterministic projection of the typed fields, not
472/// a source of truth: callers in this crate route on the typed value, never on
473/// the rendered string.
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475pub struct LiveToolDispatchTimeout {
476    timeout: Duration,
477}
478
479impl LiveToolDispatchTimeout {
480    #[must_use]
481    pub fn new(timeout: Duration) -> Self {
482        Self { timeout }
483    }
484
485    #[must_use]
486    pub fn timeout(&self) -> Duration {
487        self.timeout
488    }
489}
490
491impl std::fmt::Display for LiveToolDispatchTimeout {
492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493        write!(f, "tool dispatch timeout after {:?}", self.timeout)
494    }
495}
496
497/// Runtime-side projection sink consumed by [`LiveAdapterHost`].
498///
499/// This is the seam through which adapter observations become canonical
500/// Meerkat semantic facts. Surfaces (RPC/REST/WASM) provide an implementation
501/// that bridges into [`meerkat_core::SessionService`] / `EventInjector` /
502/// observer streams. Tests use [`RecordingProjectionSink`] (in this module's
503/// `#[cfg(test)]` block) to assert routing decisions deterministically.
504/// Provider-side identity carried alongside a transcript fragment.
505///
506/// All fields are `Option<&str>` / `Option<u32>` because the underlying
507/// `LiveAdapterObservation` variants emit them as `#[serde(default,
508/// skip_serializing_if = "Option::is_none")]` per A11. Bundling them in one
509/// struct keeps sink trait signatures readable and lets sink implementations
510/// pattern-match the fields they care about without the call sites passing
511/// six separate arguments.
512///
513/// Variants:
514/// - `UserTranscriptFinal` carries `provider_item_id`, `previous_item_id`,
515///   `content_index` (no `response_id` / `delta_id` — those are
516///   assistant-only).
517/// - `AssistantTextDelta` carries the full set including `response_id` and
518///   `delta_id`.
519/// - `AssistantTranscriptFinal` carries `provider_item_id` (always present),
520///   `previous_item_id`, `content_index`, `response_id` (no `delta_id` — a
521///   final is not a delta).
522#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
523pub struct LiveTranscriptIdentity<'a> {
524    pub provider_item_id: Option<&'a str>,
525    pub previous_item_id: Option<&'a str>,
526    pub content_index: Option<u32>,
527    pub response_id: Option<&'a str>,
528    pub delta_id: Option<&'a str>,
529}
530
531impl<'a> LiveTranscriptIdentity<'a> {
532    /// Build identity for a user-side observation (response/delta IDs are
533    /// always `None` on the user side).
534    pub fn user(
535        provider_item_id: Option<&'a str>,
536        previous_item_id: Option<&'a str>,
537        content_index: Option<u32>,
538    ) -> Self {
539        Self {
540            provider_item_id,
541            previous_item_id,
542            content_index,
543            response_id: None,
544            delta_id: None,
545        }
546    }
547
548    /// Build identity for a streaming assistant delta (full set carried).
549    pub fn assistant_delta(
550        provider_item_id: Option<&'a str>,
551        previous_item_id: Option<&'a str>,
552        content_index: Option<u32>,
553        response_id: Option<&'a str>,
554        delta_id: Option<&'a str>,
555    ) -> Self {
556        Self {
557            provider_item_id,
558            previous_item_id,
559            content_index,
560            response_id,
561            delta_id,
562        }
563    }
564
565    /// Build identity for an assistant final (no `delta_id`, but everything
566    /// else is meaningful and `provider_item_id` is required upstream).
567    pub fn assistant_final(
568        provider_item_id: &'a str,
569        previous_item_id: Option<&'a str>,
570        content_index: Option<u32>,
571        response_id: Option<&'a str>,
572    ) -> Self {
573        Self {
574            provider_item_id: Some(provider_item_id),
575            previous_item_id,
576            content_index,
577            response_id,
578            delta_id: None,
579        }
580    }
581
582    /// Resolve the required identity triple for an assistant **delta** event
583    /// (D199): `response_id`, `delta_id`, and `item_id` (provider item id).
584    ///
585    /// The sink previously coalesced each missing id to an empty string via
586    /// `unwrap_or_default()`, emitting an [`RealtimeTranscriptEvent`] whose
587    /// identity was empty-string truth — a delta that cannot be bound back to
588    /// any response/item. This accessor fails closed: a missing required id
589    /// yields a typed [`LiveTranscriptIdentityError`] naming the absent field,
590    /// so the projection rejects the malformed delta rather than fabricating
591    /// empty identity.
592    pub fn require_delta_identity(&self) -> Result<DeltaIdentity<'a>, LiveTranscriptIdentityError> {
593        let response_id = self
594            .response_id
595            .ok_or(LiveTranscriptIdentityError::MissingResponseId)?;
596        let delta_id = self
597            .delta_id
598            .ok_or(LiveTranscriptIdentityError::MissingDeltaId)?;
599        let item_id = self
600            .provider_item_id
601            .ok_or(LiveTranscriptIdentityError::MissingItemId)?;
602        Ok(DeltaIdentity {
603            response_id,
604            delta_id,
605            item_id,
606            previous_item_id: self.previous_item_id,
607            content_index: self.content_index,
608        })
609    }
610}
611
612/// The required identity triple resolved for an assistant **delta** event
613/// (D199). Produced by [`LiveTranscriptIdentity::require_delta_identity`];
614/// guarantees `response_id` / `delta_id` / `item_id` are present (non-empty
615/// fail-closed), so the realtime-transcript layer can key per-response staging
616/// without empty-string identity truth.
617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
618pub struct DeltaIdentity<'a> {
619    pub response_id: &'a str,
620    pub delta_id: &'a str,
621    pub item_id: &'a str,
622    pub previous_item_id: Option<&'a str>,
623    pub content_index: Option<u32>,
624}
625
626/// Typed failure when a transcript identity is missing a required id (D199).
627///
628/// Replaces the prior `unwrap_or_default()` empty-string coalescing in the
629/// projection sink: a delta lacking `response_id` / `delta_id` / `item_id`
630/// fails closed with a named field rather than emitting empty-string identity.
631#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
632#[non_exhaustive]
633pub enum LiveTranscriptIdentityError {
634    #[error("transcript delta is missing a required response_id")]
635    MissingResponseId,
636    #[error("transcript delta is missing a required delta_id")]
637    MissingDeltaId,
638    #[error("transcript delta is missing a required item_id")]
639    MissingItemId,
640}
641
642#[async_trait::async_trait]
643pub trait LiveProjectionSink: Send + Sync {
644    /// Append a finalized user transcript fragment to canonical session history.
645    async fn append_user_transcript(
646        &self,
647        session_id: &SessionId,
648        text: &str,
649        identity: LiveTranscriptIdentity<'_>,
650    ) -> Result<(), LiveProjectionError>;
651
652    /// Append a streaming **display-text** delta (authored output) to
653    /// canonical session history. Flushed as `AssistantBlock::Text` at
654    /// turn boundary.
655    ///
656    /// T6: distinct from [`Self::append_assistant_transcript_delta`] —
657    /// display text is preserved across barge-in (the user is not
658    /// "speaking over" written output).
659    async fn append_assistant_text_delta(
660        &self,
661        session_id: &SessionId,
662        delta: &str,
663        identity: LiveTranscriptIdentity<'_>,
664    ) -> Result<(), LiveProjectionError>;
665
666    /// Append a streaming **spoken-transcript** delta (audio-derived
667    /// output) to canonical session history. Flushed as
668    /// `AssistantBlock::Transcript { source: Spoken, .. }` at turn
669    /// boundary.
670    ///
671    /// T6: distinct from [`Self::append_assistant_text_delta`] — the
672    /// transcript lane is what the model *said*, not what it *wrote*, and
673    /// is dropped on barge-in (T7).
674    async fn append_assistant_transcript_delta(
675        &self,
676        session_id: &SessionId,
677        delta: &str,
678        identity: LiveTranscriptIdentity<'_>,
679    ) -> Result<(), LiveProjectionError>;
680
681    /// Append a finalized **display-text** assistant block (with stop
682    /// reason + usage). Flushed as `AssistantBlock::Text` when
683    /// [`Self::signal_turn_completed`] drains the per-response buffer.
684    ///
685    /// R6: the trait passes `response_id` through so the sink can key its
686    /// per-turn buffer on `(SessionId, response_id)` — a stale or
687    /// overlapping `response.done` cannot flush the wrong buffered final.
688    async fn append_assistant_text_final(
689        &self,
690        session_id: &SessionId,
691        text: &str,
692        identity: LiveTranscriptIdentity<'_>,
693        stop_reason: StopReason,
694        usage: Usage,
695        response_id: Option<&str>,
696    ) -> Result<(), LiveProjectionError>;
697
698    /// Append a finalized **spoken-transcript** assistant block (with stop
699    /// reason + usage). Flushed as
700    /// `AssistantBlock::Transcript { source: Spoken, .. }` when
701    /// [`Self::signal_turn_completed`] drains the per-response buffer.
702    ///
703    /// R6: same `(SessionId, response_id)` keying as the text-final
704    /// variant. T7: barge-in drains buffered transcript blocks but leaves
705    /// buffered text blocks untouched.
706    async fn append_assistant_transcript_final(
707        &self,
708        session_id: &SessionId,
709        text: &str,
710        identity: LiveTranscriptIdentity<'_>,
711        stop_reason: StopReason,
712        usage: Usage,
713        response_id: Option<&str>,
714    ) -> Result<(), LiveProjectionError>;
715
716    /// Project an assistant transcript truncation (barge-in side effect).
717    ///
718    /// `response_id` and `content_index` are the provider-supplied identity
719    /// fields the runtime's realtime-transcript layer needs to fold the
720    /// truncation into the staged response. When `response_id` is `None`,
721    /// implementors must surface a typed [`LiveProjectionError::Rejected`]
722    /// rather than silently committing an empty value (P1#3).
723    async fn truncate_assistant_transcript(
724        &self,
725        session_id: &SessionId,
726        provider_item_id: Option<&str>,
727        previous_item_id: Option<&str>,
728        content_index: Option<u32>,
729        response_id: Option<&str>,
730        text: Option<&str>,
731    ) -> Result<(), LiveProjectionError>;
732
733    /// Project a turn-interrupt fact through the same path the user-facing
734    /// interrupt RPC uses.
735    ///
736    /// G4 (P1): `response_id` carries the in-flight provider response id from
737    /// [`LiveAdapterObservation::TurnInterrupted`]. When the barge-in arrives
738    /// before any transcript delta has been staged, the sink would otherwise
739    /// have nothing to bind the truncation to; with the response id plumbed
740    /// through, the sink can scope truncation to the right response. Optional
741    /// because not every adapter or interrupt source surfaces a response id
742    /// (synthetic interrupts, transcript-only providers, the user-facing
743    /// `live/interrupt` RPC).
744    async fn signal_turn_interrupt(
745        &self,
746        session_id: &SessionId,
747        response_id: Option<&str>,
748    ) -> Result<(), LiveProjectionError>;
749
750    /// Project a transport-level output-audio delivery degradation.
751    ///
752    /// K16: when a transport drops queued output-audio packets (e.g. the
753    /// WebRTC RTP pacing queue is full or closed), the dropped-delivery fact
754    /// must reach the session through the same typed host-signal seam that
755    /// transport barge-ins use — not a transport-local counter with no live
756    /// reader. `dropped` is the cumulative count of output-audio packets the
757    /// transport failed to deliver on this channel's session.
758    async fn signal_output_audio_degraded(
759        &self,
760        session_id: &SessionId,
761        dropped: u64,
762    ) -> Result<(), LiveProjectionError>;
763
764    /// Mark the live turn complete in canonical session state.
765    ///
766    /// R6: `response_id` carries the provider's response identifier from
767    /// [`LiveAdapterObservation::TurnCompleted`] so the sink can drain the
768    /// matching `(SessionId, response_id)` buffer slot — not just by
769    /// session id, which lets a stale or overlapping `response.done` flush
770    /// the wrong buffered transcript.
771    async fn signal_turn_completed(
772        &self,
773        session_id: &SessionId,
774        stop_reason: StopReason,
775        usage: Usage,
776        response_id: Option<&str>,
777    ) -> Result<(), LiveProjectionError>;
778
779    /// Surface a terminal adapter error at the session level.
780    async fn signal_terminal_error(
781        &self,
782        session_id: &SessionId,
783        code: LiveAdapterErrorCode,
784        message: &str,
785    ) -> Result<(), LiveProjectionError>;
786
787    /// Append a structured realtime transcript event to canonical session state.
788    ///
789    /// P1#2: provider adapters now emit `LiveAdapterObservation::RealtimeTranscript`
790    /// for events the host previously dropped on the floor (`ItemObserved`,
791    /// `ItemSkipped`, `AssistantTurnCompleted`, `AssistantTurnInterrupted`).
792    /// Routing them through this seam wires those events into the session
793    /// runtime's idempotent ordering / staging machinery — the same path that
794    /// already handles streaming assistant deltas.
795    ///
796    /// R6-6 (P3 dogma): no default body. Every sink — production, no-op, and
797    /// test fakes alike — must explicitly handle realtime transcript events.
798    /// A default `Ok(())` would let a forgetful "mandatory" sink silently
799    /// drop `ItemObserved` / `AssistantTurnCompleted` / etc., which is the
800    /// same fail-open shape R5-1 closed at construction time.
801    async fn append_realtime_transcript(
802        &self,
803        session_id: &SessionId,
804        event: &RealtimeTranscriptEvent,
805    ) -> Result<RealtimeTranscriptApplyOutcome, LiveProjectionError>;
806}
807
808/// Errors returned by [`LiveProjectionSink`] implementations.
809///
810/// D301: failure semantics are typed, not prose-parsed. Each underlying
811/// [`meerkat_core::SessionError`] that a sink encounters lands in a *distinct*
812/// variant via [`LiveProjectionError::from_session_error`], carrying the
813/// session error's stable `error_code` so downstream callers route on a typed
814/// class rather than reparsing `SessionError::to_string()`. The previous sink
815/// mapper collapsed every non-`NotFound`/`Unsupported` variant into
816/// `Internal(other.to_string())`, erasing the typed cause.
817#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
818#[non_exhaustive]
819pub enum LiveProjectionError {
820    #[error("session {0} not found")]
821    SessionNotFound(SessionId),
822    /// The session service refused the projection (typed
823    /// `SessionError::Unsupported`).
824    #[error("projection rejected: {0}")]
825    Rejected(String),
826    /// A turn is already in progress on the target session
827    /// (`SessionError::Busy`).
828    #[error("session busy: {0}")]
829    SessionBusy(SessionId),
830    /// No turn is currently running on the target session
831    /// (`SessionError::NotRunning`).
832    #[error("session not running: {0}")]
833    SessionNotRunning(SessionId),
834    /// A required session capability (persistence / compaction) is disabled.
835    /// Carries the disabled capability's stable session error code.
836    #[error("session capability disabled ({code}): {message}")]
837    CapabilityDisabled { code: &'static str, message: String },
838    /// A session-level failure whose typed cause is identified by the stable
839    /// `SessionError::code` (store error, agent error, structured failure).
840    /// Carries the code so callers route on the typed class, not the message.
841    #[error("session error [{code}]: {message}")]
842    Session { code: &'static str, message: String },
843}
844
845impl LiveProjectionError {
846    /// Classify a [`meerkat_core::SessionError`] into a typed
847    /// [`LiveProjectionError`] variant (D301).
848    ///
849    /// `session_id` is the projection target, used to populate identity-bearing
850    /// variants. Every `SessionError` lands in a distinct typed variant
851    /// carrying the session error's stable `code()` — no variant is collapsed
852    /// into a prose-only `Internal(to_string())`. This is the single
853    /// classification owner; surfaces route through it instead of re-deriving
854    /// the mapping per call site.
855    #[must_use]
856    pub fn from_session_error(session_id: &SessionId, err: meerkat_core::SessionError) -> Self {
857        use meerkat_core::SessionError;
858        // Compute the stable code + human-readable message before the match
859        // consumes `err`. `code()` returns a `&'static str` and `to_string()`
860        // borrows `err` via `Display`, so both are available before the move.
861        let code = err.code();
862        let message = err.to_string();
863        match err {
864            SessionError::NotFound { .. } => Self::SessionNotFound(session_id.clone()),
865            SessionError::Unsupported(reason) => Self::Rejected(reason),
866            SessionError::Busy { id } => Self::SessionBusy(id),
867            SessionError::NotRunning { id } => Self::SessionNotRunning(id),
868            SessionError::PersistenceDisabled | SessionError::CompactionDisabled => {
869                Self::CapabilityDisabled { code, message }
870            }
871            // Exhaustive over the remaining `SessionError` kinds (no `_`
872            // catch-all): a store error, an agent-level failure, or a
873            // structured `FailedWithData` all carry the stable typed `code`
874            // into `Session { code, message }` rather than a prose-only
875            // `Internal(to_string())`. Spelling these out means a future
876            // `SessionError` variant forces a compile error here instead of
877            // silently folding into a catch-all.
878            SessionError::Store(_)
879            | SessionError::Agent(_)
880            | SessionError::DurableTailHeldForRecovery { .. }
881            | SessionError::DurableEvidenceQuarantined { .. }
882            | SessionError::FailedWithData { .. } => Self::Session { code, message },
883        }
884    }
885}
886
887/// No-op projection sink for tests and degraded configurations that
888/// intentionally opt out of routing live observations to a canonical
889/// session owner.
890///
891/// R5-1 (P2 dogma): every [`LiveAdapterHost`] now carries a mandatory
892/// projection sink so a successful "transcript projected" outcome
893/// implies a real semantic owner. Callers that genuinely want
894/// projection to drop on the floor (host smoke tests, channel-id
895/// lifecycle tests, transport plumbing tests) opt in **explicitly**
896/// by constructing this sink — the lack of a sink is no longer a
897/// silent fail-open.
898///
899/// Marked `#[doc(hidden)]` to discourage non-test use; production
900/// surfaces (`rkat-rpc`) wire `SessionServiceProjectionSink` instead.
901#[doc(hidden)]
902#[derive(Debug, Default)]
903pub struct NoOpProjectionSink;
904
905#[async_trait::async_trait]
906impl LiveProjectionSink for NoOpProjectionSink {
907    async fn append_user_transcript(
908        &self,
909        _session_id: &SessionId,
910        _text: &str,
911        _identity: LiveTranscriptIdentity<'_>,
912    ) -> Result<(), LiveProjectionError> {
913        Ok(())
914    }
915
916    async fn append_assistant_text_delta(
917        &self,
918        _session_id: &SessionId,
919        _delta: &str,
920        _identity: LiveTranscriptIdentity<'_>,
921    ) -> Result<(), LiveProjectionError> {
922        Ok(())
923    }
924
925    async fn append_assistant_transcript_delta(
926        &self,
927        _session_id: &SessionId,
928        _delta: &str,
929        _identity: LiveTranscriptIdentity<'_>,
930    ) -> Result<(), LiveProjectionError> {
931        Ok(())
932    }
933
934    async fn append_assistant_text_final(
935        &self,
936        _session_id: &SessionId,
937        _text: &str,
938        _identity: LiveTranscriptIdentity<'_>,
939        _stop_reason: StopReason,
940        _usage: Usage,
941        _response_id: Option<&str>,
942    ) -> Result<(), LiveProjectionError> {
943        Ok(())
944    }
945
946    async fn append_assistant_transcript_final(
947        &self,
948        _session_id: &SessionId,
949        _text: &str,
950        _identity: LiveTranscriptIdentity<'_>,
951        _stop_reason: StopReason,
952        _usage: Usage,
953        _response_id: Option<&str>,
954    ) -> Result<(), LiveProjectionError> {
955        Ok(())
956    }
957
958    async fn truncate_assistant_transcript(
959        &self,
960        _session_id: &SessionId,
961        _provider_item_id: Option<&str>,
962        _previous_item_id: Option<&str>,
963        _content_index: Option<u32>,
964        _response_id: Option<&str>,
965        _text: Option<&str>,
966    ) -> Result<(), LiveProjectionError> {
967        Ok(())
968    }
969
970    async fn signal_turn_interrupt(
971        &self,
972        _session_id: &SessionId,
973        _response_id: Option<&str>,
974    ) -> Result<(), LiveProjectionError> {
975        Ok(())
976    }
977
978    async fn signal_output_audio_degraded(
979        &self,
980        _session_id: &SessionId,
981        _dropped: u64,
982    ) -> Result<(), LiveProjectionError> {
983        Ok(())
984    }
985
986    async fn signal_turn_completed(
987        &self,
988        _session_id: &SessionId,
989        _stop_reason: StopReason,
990        _usage: Usage,
991        _response_id: Option<&str>,
992    ) -> Result<(), LiveProjectionError> {
993        Ok(())
994    }
995
996    async fn signal_terminal_error(
997        &self,
998        _session_id: &SessionId,
999        _code: LiveAdapterErrorCode,
1000        _message: &str,
1001    ) -> Result<(), LiveProjectionError> {
1002        Ok(())
1003    }
1004
1005    /// R6-6 (P3 dogma): explicit no-op. The whole point of [`NoOpProjectionSink`]
1006    /// is that no canonical projection happens — making this an explicit body
1007    /// (rather than relying on a trait default) means the dogma is visible at
1008    /// the implementation site, not hidden in a default that future fakes
1009    /// could silently inherit.
1010    async fn append_realtime_transcript(
1011        &self,
1012        _session_id: &SessionId,
1013        _event: &RealtimeTranscriptEvent,
1014    ) -> Result<RealtimeTranscriptApplyOutcome, LiveProjectionError> {
1015        Ok(RealtimeTranscriptApplyOutcome::default())
1016    }
1017}
1018
1019// ---------------------------------------------------------------------------
1020// Per-channel state
1021// ---------------------------------------------------------------------------
1022
1023/// Closed channels are retained for [`CLOSED_CHANNEL_TTL`] after
1024/// `close_channel` so post-close `live/status` can report `Closed { reason }`
1025/// instead of `ChannelNotFound` (G42).
1026const CLOSED_CHANNEL_TTL: std::time::Duration = std::time::Duration::from_secs(60);
1027
1028/// Per-channel transport state tracked by the host.
1029struct ChannelState {
1030    session_id: SessionId,
1031    /// Generated-committed transport status cache. The host can reserve typed
1032    /// status observations, but this cache changes only after generated
1033    /// MeerkatMachine status authority returns a commit handoff.
1034    status: LiveAdapterStatus,
1035    /// Host observation nonce. This can advance while collecting evidence, but
1036    /// generated MeerkatMachine authority owns the accepted status sequence in
1037    /// `live_channel_status_observation_sequence_by_channel`.
1038    status_observation_sequence: u64,
1039    snapshot_version: u64,
1040    refresh_acceptance_sequence: u64,
1041    command_acceptance_sequence: u64,
1042    close_observation_sequence: u64,
1043    adapter: Option<Arc<dyn LiveAdapter>>,
1044    /// Mechanical proof that no provider adapter remains reachable for this
1045    /// channel. New channels start absent, attachment clears the proof, and a
1046    /// successful adapter `close()` restores it. Generated close authority is
1047    /// forbidden from committing until this is true.
1048    physical_close_confirmed: bool,
1049    /// Transport-retention deadline for a channel the generated owner has
1050    /// already closed/terminalized. This is a resource-cache timer: public
1051    /// lifecycle/admission surfaces must route through generated authority
1052    /// before they interpret the channel as open, closed, or reusable.
1053    /// Reads are still serviced during the grace window; adapter handoffs are
1054    /// rejected because the transport handle has been released.
1055    retire_at: Option<std::time::Instant>,
1056    /// CC1 (R11 wire-signal): one-shot synthetic observation to deliver to
1057    /// the next [`LiveAdapterHost::next_observation_raw`] caller before any
1058    /// adapter poll happens.
1059    ///
1060    /// Populated by [`LiveAdapterHost::signal_terminal_error`] so a typed
1061    /// terminal error (e.g. `ConfigRejected { reason: "model_swap: ..." }`)
1062    /// flows through the same generated close-authority path as a
1063    /// provider-emitted Error observation. Without this, runtime-side
1064    /// terminations can tear down transport resources without recording the
1065    /// machine-owned close fact first.
1066    ///
1067    /// Single-slot by design: there's exactly one terminal moment per
1068    /// channel; subsequent `signal_terminal_error` calls overwrite (the
1069    /// channel is being torn down either way). Read priority — see
1070    /// `next_observation_raw` — must come before `adapter_for` so the
1071    /// synthetic obs survives a concurrent `close_channel`.
1072    pending_synthetic_obs: Option<LiveAdapterObservation>,
1073}
1074
1075/// Non-forgeable generated-authority handoff for materializing a live channel.
1076///
1077/// `LiveAdapterHost` owns transport resources only. Callers that expose
1078/// public lifecycle/admission behavior must first route `live/open` through
1079/// generated machine authority and pass the resulting admitted handoff here.
1080#[derive(Debug, Clone)]
1081pub struct LiveChannelOpenAuthority {
1082    session_id: SessionId,
1083    channel_id: LiveChannelId,
1084    sequence: u64,
1085    consumed: Arc<AtomicBool>,
1086}
1087
1088#[cfg(test)]
1089fn generated_test_llm_identity()
1090-> meerkat_machine_schema::catalog::dsl::meerkat_machine::SessionLlmIdentity {
1091    meerkat_machine_schema::catalog::dsl::meerkat_machine::SessionLlmIdentity {
1092        model: "gpt-realtime-2".to_string(),
1093        provider: meerkat_machine_schema::catalog::dsl::meerkat_machine::Provider::OpenAI,
1094        self_hosted_server_id: None,
1095        provider_params_repr: None,
1096        auth_binding: None,
1097    }
1098}
1099
1100#[cfg(test)]
1101fn generated_test_machine_for_registered_session(
1102    session_id: &SessionId,
1103) -> meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineAuthority {
1104    use meerkat_machine_schema::catalog::dsl::meerkat_machine::{
1105        MeerkatMachineAuthority, MeerkatMachineInput, MeerkatMachineMutator, MeerkatMachineSignal,
1106        SessionId as DslSessionId,
1107    };
1108
1109    let mut authority = MeerkatMachineAuthority::new();
1110    authority
1111        .apply_signal(MeerkatMachineSignal::Initialize)
1112        .expect("initialize generated MeerkatMachine authority");
1113    MeerkatMachineMutator::apply(
1114        &mut authority,
1115        MeerkatMachineInput::RegisterSession {
1116            session_id: DslSessionId(session_id.to_string()),
1117        },
1118    )
1119    .expect("register session in generated MeerkatMachine authority");
1120    authority
1121}
1122
1123#[cfg(test)]
1124fn generated_test_live_channel_status(
1125    status: &LiveAdapterStatus,
1126) -> (
1127    meerkat_machine_schema::catalog::dsl::meerkat_machine::LiveChannelPublicStatus,
1128    Option<meerkat_machine_schema::catalog::dsl::meerkat_machine::LiveChannelDegradationReason>,
1129    Option<String>,
1130) {
1131    use meerkat_core::live_adapter::LiveDegradationReason;
1132    use meerkat_machine_schema::catalog::dsl::meerkat_machine::{
1133        LiveChannelDegradationReason as DslReason, LiveChannelPublicStatus as DslStatus,
1134    };
1135
1136    match status {
1137        LiveAdapterStatus::Idle => (DslStatus::Idle, None, None),
1138        LiveAdapterStatus::Opening => (DslStatus::Opening, None, None),
1139        LiveAdapterStatus::Ready => (DslStatus::Ready, None, None),
1140        LiveAdapterStatus::Closing => (DslStatus::Closing, None, None),
1141        LiveAdapterStatus::Closed => (DslStatus::Closed, None, None),
1142        LiveAdapterStatus::Degraded { reason } => match reason {
1143            LiveDegradationReason::RateLimited => {
1144                (DslStatus::Degraded, Some(DslReason::RateLimited), None)
1145            }
1146            LiveDegradationReason::ProviderThrottled => (
1147                DslStatus::Degraded,
1148                Some(DslReason::ProviderThrottled),
1149                None,
1150            ),
1151            LiveDegradationReason::NetworkUnstable => {
1152                (DslStatus::Degraded, Some(DslReason::NetworkUnstable), None)
1153            }
1154            LiveDegradationReason::Other { detail } => (
1155                DslStatus::Degraded,
1156                Some(DslReason::Other),
1157                Some(detail.clone().into_owned()),
1158            ),
1159            other => (
1160                DslStatus::Degraded,
1161                Some(DslReason::Unknown),
1162                Some(format!("{other:?}")),
1163            ),
1164        },
1165        other => (
1166            DslStatus::Degraded,
1167            Some(DslReason::Unknown),
1168            Some(format!("{other:?}")),
1169        ),
1170    }
1171}
1172
1173impl LiveChannelOpenAuthority {
1174    #[must_use]
1175    pub fn session_id(&self) -> &SessionId {
1176        &self.session_id
1177    }
1178
1179    #[must_use]
1180    pub fn channel_id(&self) -> &LiveChannelId {
1181        &self.channel_id
1182    }
1183
1184    #[must_use]
1185    pub fn sequence(&self) -> u64 {
1186        self.sequence
1187    }
1188
1189    fn consume_once(&self) -> Result<(), LiveAdapterHostError> {
1190        self.consumed
1191            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1192            .map(|_| ())
1193            .map_err(|_| LiveAdapterHostError::OpenAuthorityAlreadyConsumed)
1194    }
1195
1196    #[cfg_attr(not(meerkat_internal_generated_authority_bridge), allow(dead_code))]
1197    fn from_generated_parts(
1198        session_id: SessionId,
1199        channel_id: LiveChannelId,
1200        sequence: u64,
1201    ) -> Self {
1202        Self {
1203            session_id,
1204            channel_id,
1205            sequence,
1206            consumed: Arc::new(AtomicBool::new(false)),
1207        }
1208    }
1209
1210    #[cfg(test)]
1211    fn from_generated_test_machine(session_id: SessionId, channel_id: LiveChannelId) -> Self {
1212        let mut authority = generated_test_machine_for_registered_session(&session_id);
1213        let channel_id_string = channel_id.as_str().to_owned();
1214        let transition = meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
1215            &mut authority,
1216            meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::ResolveLiveOpenAdmission {
1217                session_id: session_id.to_string(),
1218                channel_id: channel_id_string.clone(),
1219                llm_identity: generated_test_llm_identity(),
1220            },
1221        )
1222        .expect("generated MeerkatMachine live-open admission");
1223        let sequence = transition
1224            .effects()
1225            .iter()
1226            .find_map(|effect| match effect {
1227                meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineEffect::LiveOpenAdmissionResolved {
1228                    channel_id: effect_channel_id,
1229                    admitted: true,
1230                    sequence,
1231                    ..
1232                } if *effect_channel_id == channel_id_string => Some(*sequence),
1233                _ => None,
1234            })
1235            .expect("generated live-open admission effect");
1236        Self::from_generated_parts(session_id, channel_id, sequence)
1237    }
1238}
1239
1240#[cfg(meerkat_internal_generated_authority_bridge)]
1241#[allow(improper_ctypes_definitions, unsafe_code)]
1242unsafe extern "Rust" {
1243    #[link_name = concat!(
1244        "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_live_open_admission_",
1245        env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1246    )]
1247    fn runtime_live_open_admission_generated_authority_bridge_token_is_valid(
1248        token: &(dyn std::any::Any + Send + Sync),
1249    ) -> bool;
1250
1251    #[link_name = concat!(
1252        "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_live_close_result_",
1253        env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1254    )]
1255    fn runtime_live_close_result_generated_authority_bridge_token_is_valid(
1256        token: &(dyn std::any::Any + Send + Sync),
1257    ) -> bool;
1258
1259    #[link_name = concat!(
1260        "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_live_channel_status_result_",
1261        env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1262    )]
1263    fn runtime_live_channel_status_result_generated_authority_bridge_token_is_valid(
1264        token: &(dyn std::any::Any + Send + Sync),
1265    ) -> bool;
1266}
1267
1268#[cfg(meerkat_internal_generated_authority_bridge)]
1269#[doc(hidden)]
1270#[allow(improper_ctypes_definitions, unsafe_code)]
1271#[unsafe(export_name = concat!(
1272    "__meerkat_live_runtime_generated_live_channel_open_authority_build_v1_",
1273    env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1274))]
1275pub(crate) extern "Rust" fn runtime_generated_live_channel_open_authority_build(
1276    token: &'static (dyn std::any::Any + Send + Sync),
1277    session_id: SessionId,
1278    channel_id: LiveChannelId,
1279    sequence: u64,
1280) -> Result<LiveChannelOpenAuthority, String> {
1281    #[allow(unsafe_code)]
1282    let valid =
1283        unsafe { runtime_live_open_admission_generated_authority_bridge_token_is_valid(token) };
1284    if !valid {
1285        return Err(
1286            "live channel open authority requires the generated runtime admission bridge token"
1287                .into(),
1288        );
1289    }
1290    Ok(LiveChannelOpenAuthority::from_generated_parts(
1291        session_id, channel_id, sequence,
1292    ))
1293}
1294
1295#[cfg(meerkat_internal_generated_authority_bridge)]
1296#[doc(hidden)]
1297#[allow(improper_ctypes_definitions, unsafe_code)]
1298#[unsafe(export_name = concat!(
1299    "__meerkat_live_runtime_generated_live_channel_close_commit_authority_build_v1_",
1300    env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1301))]
1302pub(crate) extern "Rust" fn runtime_generated_live_channel_close_commit_authority_build(
1303    token: &'static (dyn std::any::Any + Send + Sync),
1304    channel_id: String,
1305    close_sequence: u64,
1306) -> Result<LiveChannelCloseCommitAuthority, String> {
1307    #[allow(unsafe_code)]
1308    let valid =
1309        unsafe { runtime_live_close_result_generated_authority_bridge_token_is_valid(token) };
1310    if !valid {
1311        return Err(
1312            "live channel close commit authority requires the generated runtime close bridge token"
1313                .into(),
1314        );
1315    }
1316    Ok(LiveChannelCloseCommitAuthority::from_generated_parts(
1317        channel_id,
1318        close_sequence,
1319    ))
1320}
1321
1322#[cfg(meerkat_internal_generated_authority_bridge)]
1323#[doc(hidden)]
1324#[allow(improper_ctypes_definitions, unsafe_code)]
1325#[unsafe(export_name = concat!(
1326    "__meerkat_live_runtime_generated_live_channel_status_commit_authority_build_v1_",
1327    env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1328))]
1329pub(crate) extern "Rust" fn runtime_generated_live_channel_status_commit_authority_build(
1330    token: &'static (dyn std::any::Any + Send + Sync),
1331    channel_id: String,
1332    status_observation_sequence: u64,
1333) -> Result<LiveChannelStatusCommitAuthority, String> {
1334    #[allow(unsafe_code)]
1335    let valid = unsafe {
1336        runtime_live_channel_status_result_generated_authority_bridge_token_is_valid(token)
1337    };
1338    if !valid {
1339        return Err(
1340            "live channel status commit authority requires the generated runtime status bridge token"
1341                .into(),
1342        );
1343    }
1344    Ok(LiveChannelStatusCommitAuthority::from_generated_parts(
1345        channel_id,
1346        status_observation_sequence,
1347    ))
1348}
1349
1350/// Errors from the live adapter host.
1351#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1352#[non_exhaustive]
1353pub enum LiveAdapterHostError {
1354    #[error("channel {0} not found")]
1355    ChannelNotFound(LiveChannelId),
1356    #[error("session {0} not found")]
1357    SessionNotFound(SessionId),
1358    #[error("channel {0} is not ready (status: {1:?})")]
1359    ChannelNotReady(LiveChannelId, LiveAdapterStatus),
1360    #[error("session {0} already has an active channel")]
1361    SessionAlreadyBound(SessionId),
1362    #[error("live channel open lacks generated admission authority")]
1363    OpenNotAuthorized,
1364    #[error("live channel open authority was already consumed")]
1365    OpenAuthorityAlreadyConsumed,
1366    #[error("live channel close lacks generated commit authority")]
1367    CloseNotAuthorized,
1368    #[error("live channel close authority was already consumed")]
1369    CloseAuthorityAlreadyConsumed,
1370    #[error("live channel status lacks generated commit authority")]
1371    StatusNotAuthorized,
1372    #[error("live channel status authority was already consumed")]
1373    StatusAuthorityAlreadyConsumed,
1374    #[error("no adapter attached to channel {0}")]
1375    NoAdapter(LiveChannelId),
1376    #[error("unsupported host command: {0}")]
1377    UnsupportedCommand(&'static str),
1378    /// E29: typed adapter error preserved structurally (not flattened to String).
1379    #[error(transparent)]
1380    AdapterError(#[from] LiveAdapterError),
1381    #[error("projection sink error: {0}")]
1382    ProjectionError(#[from] LiveProjectionError),
1383}
1384
1385impl LiveAdapterHostError {
1386    /// Stable typed reason code for this host error (D153).
1387    ///
1388    /// Transport-fatal errors surface this as `WsErrorFrame.reason`; scoped
1389    /// adapter rejections surface their structured code through
1390    /// `CommandRejected`. In both cases clients route on a typed class rather
1391    /// than reparsing a human-readable message. The codes are stable wire
1392    /// strings; adding a variant must add its code here (the match is
1393    /// exhaustive over the `#[non_exhaustive]` enum from inside the crate).
1394    #[must_use]
1395    pub fn reason_code(&self) -> &'static str {
1396        match self {
1397            Self::ChannelNotFound(_) => "channel_not_found",
1398            Self::SessionNotFound(_) => "session_not_found",
1399            Self::ChannelNotReady(..) => "channel_not_ready",
1400            Self::SessionAlreadyBound(_) => "session_already_bound",
1401            Self::OpenNotAuthorized => "open_not_authorized",
1402            Self::OpenAuthorityAlreadyConsumed => "open_authority_already_consumed",
1403            Self::CloseNotAuthorized => "close_not_authorized",
1404            Self::CloseAuthorityAlreadyConsumed => "close_authority_already_consumed",
1405            Self::StatusNotAuthorized => "status_not_authorized",
1406            Self::StatusAuthorityAlreadyConsumed => "status_authority_already_consumed",
1407            Self::NoAdapter(_) => "no_adapter",
1408            Self::UnsupportedCommand(_) => "unsupported_command",
1409            Self::AdapterError(_) => "adapter_error",
1410            Self::ProjectionError(_) => "projection_error",
1411        }
1412    }
1413}
1414
1415/// Observation routing decision — what the host does with an adapter
1416/// observation.
1417///
1418/// Marked `#[non_exhaustive]` (H49) because new variants will be added as
1419/// the projection grows (e.g. provider-native barge-in audio cursor).
1420#[derive(Debug, Clone, PartialEq)]
1421#[non_exhaustive]
1422pub enum ObservationRouting {
1423    AppendTranscript,
1424    /// Pass-through of a structured [`RealtimeTranscriptEvent`] from the
1425    /// provider adapter. Routed to [`LiveProjectionSink::append_realtime_transcript`]
1426    /// so the session layer's idempotent ordering / staging machinery owns
1427    /// materialization (P1#2). Replaces the prior `Noop` fallthrough that
1428    /// silently dropped these structured events.
1429    AppendRealtimeTranscript,
1430    DispatchToolCall {
1431        provider_call_id: String,
1432        tool_name: String,
1433    },
1434    SignalInterrupt,
1435    UpdateStatus(LiveAdapterStatus),
1436    TerminalError,
1437    /// R5-9: a non-terminal scoped command rejection. Distinct from
1438    /// [`Self::TerminalError`] so the host's projection refuses to
1439    /// close the channel for typed-input failures the client can retry.
1440    CommandRejection,
1441    Noop,
1442}
1443
1444/// Session-scoped tool executor used by [`LiveAdapterHost`].
1445///
1446/// The provider only reports a tool call and a live channel id. The host
1447/// resolves that channel back to the owning [`SessionId`] and hands the call to
1448/// this trait. Production surfaces implement it by delegating to the
1449/// canonical session service / runtime tool dispatcher.
1450#[async_trait::async_trait]
1451pub trait LiveToolDispatcher: Send + Sync {
1452    async fn dispatch_live_tool_call(
1453        &self,
1454        session_id: &SessionId,
1455        call: ToolCall,
1456    ) -> Result<ToolDispatchOutcome, LiveToolDispatchError>;
1457}
1458
1459/// Error returned by a [`LiveToolDispatcher`].
1460///
1461/// D113: failure semantics are typed, not prose-parsed. A
1462/// [`LiveToolDispatcher`] backed by the canonical session runtime resolves a
1463/// live tool call through the session service and therefore fails with a typed
1464/// [`meerkat_core::SessionError`]. The seam classifies that error into a
1465/// *distinct* variant via [`LiveToolDispatchError::from_session_error`],
1466/// carrying the session error's stable `error_code` so the terminal class
1467/// survives back through the live adapter rather than being flattened into a
1468/// prose-only string. This mirrors the
1469/// [`LiveProjectionError::from_session_error`] classification owner.
1470#[derive(Debug, Clone, thiserror::Error)]
1471#[non_exhaustive]
1472pub enum LiveToolDispatchError {
1473    #[error(transparent)]
1474    Tool(#[from] ToolError),
1475    /// The target session does not exist (`SessionError::NotFound`).
1476    #[error("live tool dispatch target session {0} not found")]
1477    SessionNotFound(SessionId),
1478    /// A turn is already in progress on the target session
1479    /// (`SessionError::Busy`).
1480    #[error("live tool dispatch target session {0} is busy")]
1481    SessionBusy(SessionId),
1482    /// No turn is currently running on the target session
1483    /// (`SessionError::NotRunning`).
1484    #[error("live tool dispatch target session {0} is not running")]
1485    SessionNotRunning(SessionId),
1486    /// A required session capability (persistence / compaction) is disabled.
1487    /// Carries the disabled capability's stable session error code.
1488    #[error("live tool dispatch capability disabled ({code}): {message}")]
1489    CapabilityDisabled { code: &'static str, message: String },
1490    /// The session service refused the dispatch (typed
1491    /// `SessionError::Unsupported`).
1492    #[error("live tool dispatch rejected: {0}")]
1493    Rejected(String),
1494    /// A session-level failure whose typed cause is identified by the stable
1495    /// `SessionError::code` (store error, agent error, structured failure).
1496    /// Carries the code so callers route on the typed class, not the message.
1497    #[error("live tool dispatch session error [{code}]: {message}")]
1498    Session { code: &'static str, message: String },
1499}
1500
1501impl LiveToolDispatchError {
1502    /// Classify a [`meerkat_core::SessionError`] into a typed
1503    /// [`LiveToolDispatchError`] variant (D113).
1504    ///
1505    /// `session_id` is the dispatch target, used to populate identity-bearing
1506    /// variants. Every `SessionError` lands in a distinct typed variant
1507    /// carrying the session error's stable `code()` — no variant is collapsed
1508    /// into a prose-only string. This is the single classification owner; the
1509    /// RPC dispatch seam routes through it instead of re-deriving the mapping
1510    /// inline, so the terminal class (not found / busy / not running /
1511    /// capability disabled / rejected / session) survives back through the
1512    /// live adapter.
1513    #[must_use]
1514    pub fn from_session_error(session_id: &SessionId, err: meerkat_core::SessionError) -> Self {
1515        use meerkat_core::SessionError;
1516        // Compute the stable code + human-readable message before the match
1517        // consumes `err`. `code()` returns a `&'static str` and `to_string()`
1518        // borrows `err` via `Display`, so both are available before the move.
1519        let code = err.code();
1520        let message = err.to_string();
1521        match err {
1522            SessionError::NotFound { .. } => Self::SessionNotFound(session_id.clone()),
1523            SessionError::Unsupported(reason) => Self::Rejected(reason),
1524            SessionError::Busy { id } => Self::SessionBusy(id),
1525            SessionError::NotRunning { id } => Self::SessionNotRunning(id),
1526            SessionError::PersistenceDisabled | SessionError::CompactionDisabled => {
1527                Self::CapabilityDisabled { code, message }
1528            }
1529            // Exhaustive over the remaining `SessionError` kinds (no `_`
1530            // catch-all): a store error, an agent-level failure, or a
1531            // structured `FailedWithData` all carry the stable typed `code`
1532            // into `Session { code, message }` rather than a prose-only
1533            // string. Spelling these out means a future `SessionError`
1534            // variant forces a compile error here instead of silently
1535            // folding into a catch-all.
1536            SessionError::Store(_)
1537            | SessionError::Agent(_)
1538            | SessionError::DurableTailHeldForRecovery { .. }
1539            | SessionError::DurableEvidenceQuarantined { .. }
1540            | SessionError::FailedWithData { .. } => Self::Session { code, message },
1541        }
1542    }
1543}
1544
1545/// Runtime-owned host for live provider adapter sessions.
1546///
1547/// This is NOT MeerkatMachine state (dogma: adapter must not become a
1548/// second machine). It's a runtime orchestrator that:
1549/// - Owns the map of active adapter channels and their adapters
1550/// - Builds projection snapshots from canonical session state
1551/// - Routes adapter observations to the right Meerkat API
1552/// - Exposes transport bootstrap info for the surface API
1553pub struct LiveAdapterHost {
1554    inner: Mutex<HostInner>,
1555    // G41: removed `next_channel_id: AtomicU64` — channel ids are now v4 UUIDs
1556    // minted by `LiveChannelId::random_uuid()` so the per-process counter is
1557    // dead weight (and would imply a process-monotonic guarantee the new ids
1558    // intentionally do not provide).
1559    /// Mandatory projection sink (R5-1 P2 dogma).
1560    ///
1561    /// A successful [`ObservationOutcome::TranscriptAppended`] /
1562    /// [`ObservationOutcome::InterruptSignalled`] / [`ObservationOutcome::Terminal`]
1563    /// outcome implies a real semantic owner received the projection.
1564    /// Surfaces that genuinely want projections to drop on the floor
1565    /// (host smoke tests, channel-id lifecycle tests) opt in explicitly
1566    /// by constructing the host with [`NoOpProjectionSink`] — the
1567    /// lack of a sink is no longer a silent fail-open hidden in an
1568    /// `Option`.
1569    projection_sink: Arc<dyn LiveProjectionSink>,
1570    /// Late-bindable session-scoped live tool dispatcher.
1571    ///
1572    /// `Mutex<Option<...>>` rather than the prior plain `Option<...>` because
1573    /// the dispatcher may be constructed after the host has already been
1574    /// wrapped by a transport state. A builder-only `with_*` API can't reach
1575    /// that point, so we expose [`set_live_tool_dispatcher`] as a late setter.
1576    /// Reads are short and fully sync — never held across an `.await`.
1577    tool_dispatcher: std::sync::Mutex<Option<Arc<dyn LiveToolDispatcher>>>,
1578    /// Per-tool-call dispatch timeout. `dispatch_tool_call` races the
1579    /// dispatcher future against
1580    /// [`tokio::time::timeout`](tokio::time::timeout); on elapse, the host
1581    /// submits a typed `LiveAdapterCommand::SubmitToolError` to the adapter
1582    /// and returns [`ObservationOutcome::ToolCallTimedOut`] instead of
1583    /// `ToolCallDispatched`. The runtime is therefore not deadlockable by a
1584    /// dispatcher that holds a tool call forever (dogma: the adapter cannot
1585    /// stall canonical session lifecycle).
1586    ///
1587    /// Initialized to [`DEFAULT_LIVE_TOOL_TIMEOUT`] by [`Self::new`]; surfaces
1588    /// that need a different deadline budget override it via
1589    /// [`Self::with_tool_timeout`]. There is no unbounded-await mode — the
1590    /// deadline is type-enforced.
1591    tool_timeout: Duration,
1592}
1593
1594/// Default tool-call dispatch timeout used by surfaces that opt into a
1595/// timeout without specifying a value. Picked to be long enough to
1596/// accommodate reasonable tool work (HTTP fetches, database calls, model
1597/// calls invoked from a tool) but short enough that a stuck dispatcher
1598/// cannot indefinitely hold a live provider's turn.
1599pub const DEFAULT_LIVE_TOOL_TIMEOUT: Duration = Duration::from_secs(30);
1600
1601struct HostInner {
1602    /// `IndexMap` keeps insertion order stable for `active_channels` (which a
1603    /// few tests rely on as a deterministic projection).
1604    channels: IndexMap<LiveChannelId, ChannelState>,
1605    /// N80: O(1) reverse lookup so `open_channel` does not linear-scan the
1606    /// `channels` map under the host mutex when binding by `SessionId`.
1607    by_session: HashMap<SessionId, LiveChannelId>,
1608}
1609
1610impl LiveAdapterHost {
1611    /// Construct a new host with the given projection sink.
1612    ///
1613    /// R5-1 (P2 dogma): the sink is mandatory at construction so
1614    /// "projection appended" success outcomes always imply a real
1615    /// semantic owner. Tests / smoke configs that intentionally
1616    /// drop projections opt in explicitly via
1617    /// `LiveAdapterHost::new(Arc::new(NoOpProjectionSink))`.
1618    #[must_use]
1619    pub fn new(projection_sink: Arc<dyn LiveProjectionSink>) -> Self {
1620        Self {
1621            inner: Mutex::new(HostInner {
1622                channels: IndexMap::new(),
1623                by_session: HashMap::new(),
1624            }),
1625            projection_sink,
1626            tool_dispatcher: std::sync::Mutex::new(None),
1627            tool_timeout: DEFAULT_LIVE_TOOL_TIMEOUT,
1628        }
1629    }
1630
1631    /// Builder: override the per-tool-call dispatch timeout.
1632    ///
1633    /// Every `ToolCallRequested` observation triggers a dispatcher call
1634    /// wrapped in [`tokio::time::timeout`]; if the dispatcher does not
1635    /// produce a result before the deadline, the host:
1636    ///
1637    /// 1. Sends a typed `LiveAdapterCommand::SubmitToolError` to the adapter
1638    ///    so the provider can unblock the live turn.
1639    /// 2. Returns [`ObservationOutcome::ToolCallTimedOut`] (not
1640    ///    `ToolCallDispatched`) so the projection layer can audit the miss.
1641    ///
1642    /// [`Self::new`] initializes the deadline to
1643    /// [`DEFAULT_LIVE_TOOL_TIMEOUT`]; this builder overrides it.
1644    #[must_use]
1645    pub fn with_tool_timeout(mut self, timeout: Duration) -> Self {
1646        self.tool_timeout = timeout;
1647        self
1648    }
1649
1650    /// Read the configured per-tool-call dispatch timeout.
1651    #[must_use]
1652    pub fn tool_timeout(&self) -> Duration {
1653        self.tool_timeout
1654    }
1655
1656    /// Builder: install the session-scoped live tool dispatcher.
1657    #[must_use]
1658    pub fn with_live_tool_dispatcher(self, dispatcher: Arc<dyn LiveToolDispatcher>) -> Self {
1659        self.set_live_tool_dispatcher(dispatcher);
1660        self
1661    }
1662
1663    /// Late setter for the session-scoped live tool dispatcher.
1664    ///
1665    /// Surfaces (rkat-rpc) cannot construct the dispatcher until after the
1666    /// host has been wrapped inside `LiveWsState`, so a builder-only
1667    /// `with_live_tool_dispatcher` is unreachable for them. This setter
1668    /// accepts `&self` (no `mut`) and replaces whatever was previously installed.
1669    /// Subsequent `ToolCallRequested` observations dispatch through the
1670    /// new value; in-flight dispatches that already cloned an `Arc` to the
1671    /// previous dispatcher continue running with that one.
1672    pub fn set_live_tool_dispatcher(&self, dispatcher: Arc<dyn LiveToolDispatcher>) {
1673        // Lock is brief and never held across an .await — tool dispatch reads
1674        // load_dispatcher() which clones the Arc and drops the guard before
1675        // calling dispatch().
1676        if let Ok(mut slot) = self.tool_dispatcher.lock() {
1677            *slot = Some(dispatcher);
1678        }
1679        // Lock poisoning here would mean a previous panic while holding the
1680        // dispatcher lock — exceedingly unlikely (the only operations are
1681        // `Some(_)` writes and clone-on-read). Falling through silently
1682        // preserves the prior dispatcher rather than dropping the new wiring
1683        // on the floor; the late-set contract is "best effort install."
1684    }
1685
1686    /// Snapshot the currently-installed dispatcher (clones the Arc).
1687    ///
1688    /// Returns `None` if no dispatcher has been installed yet, in which case
1689    /// `dispatch_tool_call` projects `ObservationOutcome::ToolCallSkipped {
1690    /// reason: NoDispatcher }`.
1691    fn load_dispatcher(&self) -> Option<Arc<dyn LiveToolDispatcher>> {
1692        self.tool_dispatcher
1693            .lock()
1694            .ok()
1695            .and_then(|slot| slot.as_ref().map(Arc::clone))
1696    }
1697
1698    pub async fn open_channel_with_authority(
1699        &self,
1700        authority: &LiveChannelOpenAuthority,
1701    ) -> Result<LiveChannelId, LiveAdapterHostError> {
1702        authority.consume_once()?;
1703        self.open_channel_after_generated_authority(
1704            authority.session_id().clone(),
1705            authority.channel_id().clone(),
1706        )
1707        .await
1708    }
1709
1710    #[cfg(test)]
1711    pub(crate) async fn open_channel_with_generated_test_machine_authority(
1712        &self,
1713        session_id: SessionId,
1714    ) -> Result<LiveChannelId, LiveAdapterHostError> {
1715        let channel_id = LiveChannelId::random_uuid();
1716        let authority =
1717            LiveChannelOpenAuthority::from_generated_test_machine(session_id, channel_id);
1718        self.open_channel_with_authority(&authority).await
1719    }
1720
1721    async fn open_channel_after_generated_authority(
1722        &self,
1723        session_id: SessionId,
1724        channel_id: LiveChannelId,
1725    ) -> Result<LiveChannelId, LiveAdapterHostError> {
1726        let mut inner = self.inner.lock().await;
1727        Self::reap_retired_locked(&mut inner);
1728
1729        // Resource-cache consistency check after generated admission. Public
1730        // duplicate-session admission is owned by MeerkatMachine; this guard
1731        // only fails closed if the host still has a live transport binding
1732        // that contradicts the generated handoff.
1733        if let Some(existing) = inner.by_session.get(&session_id).cloned()
1734            && let Some(channel) = inner.channels.get(&existing)
1735            && channel.retire_at.is_none()
1736        {
1737            return Err(LiveAdapterHostError::SessionAlreadyBound(session_id));
1738        }
1739
1740        inner.channels.insert(
1741            channel_id.clone(),
1742            ChannelState {
1743                session_id: session_id.clone(),
1744                status: LiveAdapterStatus::Opening,
1745                status_observation_sequence: 0,
1746                snapshot_version: 0,
1747                refresh_acceptance_sequence: 0,
1748                command_acceptance_sequence: 0,
1749                close_observation_sequence: 0,
1750                adapter: None,
1751                physical_close_confirmed: true,
1752                retire_at: None,
1753                pending_synthetic_obs: None,
1754            },
1755        );
1756        inner.by_session.insert(session_id, channel_id.clone());
1757
1758        Ok(channel_id)
1759    }
1760
1761    /// Attach a live adapter to an open channel.
1762    ///
1763    /// F32: the channel does NOT become `Ready` here. Status remains
1764    /// `Opening` until the adapter emits `LiveAdapterObservation::Ready`,
1765    /// which is observed via `next_observation_raw`/`apply_observation`.
1766    pub async fn attach_adapter(
1767        &self,
1768        channel_id: &LiveChannelId,
1769        adapter: Arc<dyn LiveAdapter>,
1770    ) -> Result<(), LiveAdapterHostError> {
1771        let mut inner = self.inner.lock().await;
1772        let channel = inner
1773            .channels
1774            .get_mut(channel_id)
1775            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
1776        if channel.retire_at.is_some() {
1777            return Err(LiveAdapterHostError::ChannelNotFound(channel_id.clone()));
1778        }
1779        channel.adapter = Some(adapter);
1780        channel.physical_close_confirmed = false;
1781        // Intentionally NOT setting status to Ready (F32). Driven by
1782        // adapter observations.
1783        Ok(())
1784    }
1785
1786    fn command_acceptance_kind(
1787        command: &LiveAdapterCommand,
1788    ) -> Result<LiveCommandAcceptanceKind, LiveAdapterHostError> {
1789        match command {
1790            LiveAdapterCommand::SendInput { .. } => Ok(LiveCommandAcceptanceKind::SendInput),
1791            LiveAdapterCommand::CommitInput { .. } => Ok(LiveCommandAcceptanceKind::CommitInput),
1792            LiveAdapterCommand::Interrupt => Ok(LiveCommandAcceptanceKind::Interrupt),
1793            LiveAdapterCommand::TruncateAssistantOutput { .. } => {
1794                Ok(LiveCommandAcceptanceKind::TruncateAssistantOutput)
1795            }
1796            LiveAdapterCommand::Refresh { .. } => Err(LiveAdapterHostError::UnsupportedCommand(
1797                "refresh commands must use LiveAdapterHost::enqueue_refresh",
1798            )),
1799            _ => Err(LiveAdapterHostError::UnsupportedCommand(
1800                "live command has no public queue-acceptance authority",
1801            )),
1802        }
1803    }
1804
1805    async fn record_command_queue_acceptance(
1806        &self,
1807        channel_id: &LiveChannelId,
1808        kind: LiveCommandAcceptanceKind,
1809    ) -> Result<LiveCommandQueueAcceptance, LiveAdapterHostError> {
1810        let mut inner = self.inner.lock().await;
1811        let channel = inner
1812            .channels
1813            .get_mut(channel_id)
1814            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
1815        channel.command_acceptance_sequence = channel.command_acceptance_sequence.saturating_add(1);
1816        LiveCommandQueueAcceptance::from_host_queue_acceptance(
1817            channel_id.as_str().to_owned(),
1818            kind,
1819            channel.command_acceptance_sequence,
1820        )
1821        .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
1822    }
1823
1824    /// Send a command to the adapter on a channel.
1825    pub async fn send_command(
1826        &self,
1827        channel_id: &LiveChannelId,
1828        command: LiveAdapterCommand,
1829    ) -> Result<(), LiveAdapterHostError> {
1830        if matches!(&command, LiveAdapterCommand::Refresh { .. }) {
1831            return Err(LiveAdapterHostError::UnsupportedCommand(
1832                "refresh commands must use LiveAdapterHost::enqueue_refresh",
1833            ));
1834        }
1835        let adapter = self
1836            .adapter_for(channel_id, /* require_ready = */ false)
1837            .await?;
1838        adapter.send_command(command).await?;
1839        Ok(())
1840    }
1841
1842    /// Send a command to the adapter and return typed queue-acceptance
1843    /// evidence for public-result authority.
1844    pub async fn send_command_observed(
1845        &self,
1846        channel_id: &LiveChannelId,
1847        command: LiveAdapterCommand,
1848    ) -> Result<LiveCommandQueueAcceptance, LiveAdapterHostError> {
1849        if matches!(&command, LiveAdapterCommand::Refresh { .. }) {
1850            return Err(LiveAdapterHostError::UnsupportedCommand(
1851                "refresh commands must use LiveAdapterHost::enqueue_refresh",
1852            ));
1853        }
1854        let acceptance_kind = Self::command_acceptance_kind(&command)?;
1855        let adapter = self
1856            .adapter_for(channel_id, /* require_ready = */ false)
1857            .await?;
1858        adapter.send_command(command).await?;
1859        self.record_command_queue_acceptance(channel_id, acceptance_kind)
1860            .await
1861    }
1862
1863    /// Enqueue a refresh command and return typed queue-acceptance evidence.
1864    ///
1865    /// The acceptance receipt is minted only after the adapter command queue
1866    /// accepts the refresh. MeerkatMachine consumes the receipt to decide the
1867    /// public `live/refresh` result; the host does not construct that result.
1868    pub async fn enqueue_refresh(
1869        &self,
1870        channel_id: &LiveChannelId,
1871        snapshot: meerkat_core::live_adapter::LiveProjectionSnapshot,
1872    ) -> Result<LiveRefreshQueueAcceptance, LiveAdapterHostError> {
1873        let adapter = self
1874            .adapter_for(channel_id, /* require_ready = */ false)
1875            .await?;
1876        adapter
1877            .send_command(LiveAdapterCommand::Refresh { snapshot })
1878            .await?;
1879
1880        let mut inner = self.inner.lock().await;
1881        let channel = inner
1882            .channels
1883            .get_mut(channel_id)
1884            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
1885        channel.refresh_acceptance_sequence = channel.refresh_acceptance_sequence.saturating_add(1);
1886        LiveRefreshQueueAcceptance::from_host_queue_acceptance(
1887            channel_id.as_str().to_owned(),
1888            channel.refresh_acceptance_sequence,
1889        )
1890        .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
1891    }
1892
1893    /// Send an input chunk to the adapter on a channel.
1894    ///
1895    /// F31: adapter-mechanical guard using the observed transport status.
1896    /// Public result/status authority still lives in MeerkatMachine; this
1897    /// check prevents writes to a transport that is not ready to accept bytes.
1898    pub async fn send_input(
1899        &self,
1900        channel_id: &LiveChannelId,
1901        chunk: LiveInputChunk,
1902    ) -> Result<(), LiveAdapterHostError> {
1903        let adapter = self
1904            .adapter_for(channel_id, /* require_ready = */ true)
1905            .await?;
1906        adapter
1907            .send_command(LiveAdapterCommand::SendInput { chunk })
1908            .await?;
1909        Ok(())
1910    }
1911
1912    /// Send an input chunk and return typed queue-acceptance evidence for
1913    /// public-result authority. Public transports that do not return a
1914    /// per-frame success result use [`Self::send_input`] instead so they do
1915    /// not mint authority evidence that cannot be consumed by MeerkatMachine.
1916    pub async fn send_input_observed(
1917        &self,
1918        channel_id: &LiveChannelId,
1919        chunk: LiveInputChunk,
1920    ) -> Result<LiveCommandQueueAcceptance, LiveAdapterHostError> {
1921        let adapter = self
1922            .adapter_for(channel_id, /* require_ready = */ true)
1923            .await?;
1924        adapter
1925            .send_command(LiveAdapterCommand::SendInput { chunk })
1926            .await?;
1927        self.record_command_queue_acceptance(channel_id, LiveCommandAcceptanceKind::SendInput)
1928            .await
1929    }
1930
1931    /// Submit a tool result back to the adapter on a channel.
1932    ///
1933    /// Used by the projection contract after
1934    /// [`LiveToolDispatcher::dispatch_live_tool_call`] produces a result for
1935    /// a `ToolCallRequested` observation (A5).
1936    pub async fn submit_tool_result(
1937        &self,
1938        channel_id: &LiveChannelId,
1939        result: LiveToolResult,
1940    ) -> Result<(), LiveAdapterHostError> {
1941        self.send_command(channel_id, LiveAdapterCommand::SubmitToolResult { result })
1942            .await
1943    }
1944
1945    /// Submit a tool error back to the adapter on a channel.
1946    ///
1947    /// #270: takes the typed [`ToolCallId`] and renders it to the
1948    /// provider-native string only at the meerkat-core
1949    /// [`LiveAdapterCommand::SubmitToolError`] seam edge, where the wire
1950    /// field is owned by core and remains a bare `String`.
1951    pub async fn submit_tool_error(
1952        &self,
1953        channel_id: &LiveChannelId,
1954        call_id: ToolCallId,
1955        error: String,
1956    ) -> Result<(), LiveAdapterHostError> {
1957        self.send_command(
1958            channel_id,
1959            LiveAdapterCommand::SubmitToolError { call_id, error },
1960        )
1961        .await
1962    }
1963
1964    /// Submit a typed tool-dispatch-timeout fact back to the adapter (D281).
1965    ///
1966    /// The caller passes the typed [`LiveToolDispatchTimeout`] fact; the only
1967    /// stringification happens here, at the meerkat-core
1968    /// [`LiveAdapterCommand::SubmitToolError`] seam edge, via the fact's
1969    /// [`Display`] projection. No call site fabricates a parseable prose
1970    /// string; the typed value is the truth.
1971    pub async fn submit_tool_dispatch_timeout(
1972        &self,
1973        channel_id: &LiveChannelId,
1974        call_id: ToolCallId,
1975        timeout: LiveToolDispatchTimeout,
1976    ) -> Result<(), LiveAdapterHostError> {
1977        self.submit_tool_error(channel_id, call_id, timeout.to_string())
1978            .await
1979    }
1980
1981    /// Read the next adapter observation without applying it to canonical state.
1982    ///
1983    /// Adapter read failures are surfaced as typed terminal observations.
1984    /// The host does not commit close/retire state here; transports must route
1985    /// terminal observations through generated close feedback before cleanup.
1986    pub async fn next_observation_raw(
1987        &self,
1988        channel_id: &LiveChannelId,
1989    ) -> Result<Option<LiveAdapterObservation>, LiveAdapterHostError> {
1990        // CC1: check for a one-shot synthetic observation pushed by
1991        // `signal_terminal_error` before polling the adapter. This makes
1992        // typed terminal errors (e.g. R11 model_swap → `ConfigRejected`)
1993        // observable to the WS pump even if the underlying adapter has
1994        // already been torn down by `close_channel` — the pending slot
1995        // sits in `ChannelState`, not on the adapter. R5-3 also relies
1996        // on this slot as a fallback for adapter implementations whose
1997        // `inject_observation` is a no-op (e.g. test stubs).
1998        {
1999            let mut inner = self.inner.lock().await;
2000            if let Some(channel) = inner.channels.get_mut(channel_id)
2001                && let Some(obs) = channel.pending_synthetic_obs.take()
2002            {
2003                return Ok(Some(obs));
2004            }
2005        }
2006        let adapter = self
2007            .adapter_for(channel_id, /* require_ready = */ false)
2008            .await?;
2009        match adapter.next_observation().await {
2010            Ok(Some(obs)) => Ok(Some(obs)),
2011            Ok(None) => {
2012                // R5-3 fallback: if the adapter closed before the
2013                // synthetic observation was injected (or the adapter
2014                // implementation is a no-op stub), one final pending
2015                // check delivers the typed event before the consumer
2016                // sees end-of-stream.
2017                let mut inner = self.inner.lock().await;
2018                if let Some(channel) = inner.channels.get_mut(channel_id)
2019                    && let Some(obs) = channel.pending_synthetic_obs.take()
2020                {
2021                    return Ok(Some(obs));
2022                }
2023                Ok(None)
2024            }
2025            Err(err) => {
2026                let synthetic = LiveAdapterObservation::Error {
2027                    code: LiveAdapterErrorCode::ProviderError,
2028                    message: format!("adapter read failure: {err}"),
2029                };
2030                Ok(Some(synthetic))
2031            }
2032        }
2033    }
2034
2035    /// Project an adapter observation into canonical Meerkat semantic state.
2036    ///
2037    /// This is the heart of the projection contract (A1–A6, A10, A14):
2038    /// classify → dispatch → return a typed [`ObservationOutcome`] describing
2039    /// what was applied. Non-terminal status updates must be committed through
2040    /// generated status authority before this method is called; terminal
2041    /// status/error observations require the generated close authority path to
2042    /// commit first. Transcript / tool / interrupt routing requires the host
2043    /// to be configured with the relevant injected seams.
2044    pub async fn apply_observation(
2045        &self,
2046        channel_id: &LiveChannelId,
2047        observation: &LiveAdapterObservation,
2048    ) -> Result<ObservationOutcome, LiveAdapterHostError> {
2049        if Self::observation_requires_generated_close(observation)
2050            && !self.generated_close_has_committed(channel_id).await?
2051        {
2052            return Err(LiveAdapterHostError::CloseNotAuthorized);
2053        }
2054
2055        let routing = Self::classify_observation(observation);
2056
2057        let session_id = self.channel_session(channel_id).await?;
2058
2059        match (routing, observation) {
2060            (ObservationRouting::Noop, _) => Ok(ObservationOutcome::Noop),
2061
2062            (ObservationRouting::UpdateStatus(status), _) => {
2063                Ok(ObservationOutcome::StatusUpdated(status))
2064            }
2065
2066            (
2067                ObservationRouting::AppendTranscript,
2068                LiveAdapterObservation::UserTranscriptFinal {
2069                    provider_item_id,
2070                    previous_item_id,
2071                    content_index,
2072                    text,
2073                    ..
2074                },
2075            ) => {
2076                let identity = LiveTranscriptIdentity::user(
2077                    provider_item_id.as_deref(),
2078                    previous_item_id.as_deref(),
2079                    *content_index,
2080                );
2081                self.projection_sink
2082                    .append_user_transcript(&session_id, text, identity)
2083                    .await?;
2084                Ok(ObservationOutcome::TranscriptAppended)
2085            }
2086
2087            (
2088                ObservationRouting::AppendTranscript,
2089                LiveAdapterObservation::AssistantTextDelta {
2090                    provider_item_id,
2091                    previous_item_id,
2092                    content_index,
2093                    response_id,
2094                    delta_id,
2095                    delta,
2096                    ..
2097                },
2098            ) => {
2099                let identity = LiveTranscriptIdentity::assistant_delta(
2100                    provider_item_id.as_deref(),
2101                    previous_item_id.as_deref(),
2102                    *content_index,
2103                    response_id.as_deref(),
2104                    delta_id.as_deref(),
2105                );
2106                // T6: display text routes to the text lane; flushed as
2107                // AssistantBlock::Text.
2108                self.projection_sink
2109                    .append_assistant_text_delta(&session_id, delta, identity)
2110                    .await?;
2111                Ok(ObservationOutcome::TranscriptAppended)
2112            }
2113
2114            (
2115                ObservationRouting::AppendTranscript,
2116                LiveAdapterObservation::AssistantTranscriptDelta {
2117                    provider_item_id,
2118                    previous_item_id,
2119                    content_index,
2120                    response_id,
2121                    delta_id,
2122                    delta,
2123                    ..
2124                },
2125            ) => {
2126                let identity = LiveTranscriptIdentity::assistant_delta(
2127                    provider_item_id.as_deref(),
2128                    previous_item_id.as_deref(),
2129                    *content_index,
2130                    response_id.as_deref(),
2131                    delta_id.as_deref(),
2132                );
2133                // T6: spoken transcript routes to the transcript lane;
2134                // flushed as AssistantBlock::Transcript { source: Spoken }.
2135                self.projection_sink
2136                    .append_assistant_transcript_delta(&session_id, delta, identity)
2137                    .await?;
2138                Ok(ObservationOutcome::TranscriptAppended)
2139            }
2140
2141            (
2142                ObservationRouting::AppendTranscript,
2143                LiveAdapterObservation::AssistantTranscriptFinal {
2144                    provider_item_id,
2145                    previous_item_id,
2146                    content_index,
2147                    response_id,
2148                    text,
2149                    stop_reason,
2150                    usage,
2151                    ..
2152                },
2153            ) => {
2154                let identity = LiveTranscriptIdentity::assistant_final(
2155                    provider_item_id,
2156                    previous_item_id.as_deref(),
2157                    *content_index,
2158                    response_id.as_deref(),
2159                );
2160                // R6: forward the response_id so the sink keys its
2161                // per-turn buffer on (SessionId, response_id). T6:
2162                // spoken-transcript final routes to the transcript lane.
2163                self.projection_sink
2164                    .append_assistant_transcript_final(
2165                        &session_id,
2166                        text,
2167                        identity,
2168                        *stop_reason,
2169                        usage.clone(),
2170                        response_id.as_deref(),
2171                    )
2172                    .await?;
2173                Ok(ObservationOutcome::TranscriptAppended)
2174            }
2175
2176            (
2177                ObservationRouting::AppendTranscript,
2178                LiveAdapterObservation::AssistantTranscriptTruncated {
2179                    provider_item_id,
2180                    previous_item_id,
2181                    content_index,
2182                    response_id,
2183                    text,
2184                },
2185            ) => {
2186                self.projection_sink
2187                    .truncate_assistant_transcript(
2188                        &session_id,
2189                        provider_item_id.as_deref(),
2190                        previous_item_id.as_deref(),
2191                        *content_index,
2192                        response_id.as_deref(),
2193                        text.as_deref(),
2194                    )
2195                    .await?;
2196                Ok(ObservationOutcome::TranscriptTruncated)
2197            }
2198
2199            (
2200                ObservationRouting::AppendTranscript,
2201                LiveAdapterObservation::TurnCompleted {
2202                    response_id,
2203                    stop_reason,
2204                    usage,
2205                },
2206            ) => {
2207                // R6: pass the response_id through so the sink can
2208                // drain the matching (SessionId, response_id) buffer
2209                // slot, not just by session_id.
2210                self.projection_sink
2211                    .signal_turn_completed(
2212                        &session_id,
2213                        *stop_reason,
2214                        usage.clone(),
2215                        response_id.as_deref(),
2216                    )
2217                    .await?;
2218                Ok(ObservationOutcome::TranscriptAppended)
2219            }
2220
2221            // P1#2: structured realtime transcript events flow through the
2222            // typed sink seam so the session runtime's idempotent ordering /
2223            // staging machinery owns materialization. Mirrors the seam wave-3
2224            // wired up for assistant deltas.
2225            (
2226                ObservationRouting::AppendRealtimeTranscript,
2227                LiveAdapterObservation::RealtimeTranscript { event },
2228            ) => {
2229                let outcome = self
2230                    .projection_sink
2231                    .append_realtime_transcript(&session_id, event)
2232                    .await?;
2233                match outcome.user_content {
2234                    Some(RealtimeUserContentApplyOutcome::Committed(identity))
2235                    | Some(RealtimeUserContentApplyOutcome::AlreadyCommitted(identity)) => {
2236                        Ok(ObservationOutcome::UserContentCommitted {
2237                            observation: LiveAdapterObservation::UserContentCommitted {
2238                                idempotency_key: identity.idempotency_key,
2239                                item_id: identity.item_id,
2240                                previous_item_id: identity.previous_item_id,
2241                                content_index: identity.content_index,
2242                                media_type: identity.media_type,
2243                            },
2244                        })
2245                    }
2246                    Some(RealtimeUserContentApplyOutcome::RejectedInvalidIdentity {
2247                        idempotency_key,
2248                    }) => {
2249                        let code = LiveAdapterErrorCode::ConfigRejected {
2250                            reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputIdempotencyKeyInvalid {
2251                                max_bytes: meerkat_core::live_adapter::MAX_LIVE_IMAGE_IDEMPOTENCY_KEY_BYTES as u64,
2252                                actual_bytes: idempotency_key.len() as u64,
2253                            },
2254                        };
2255                        self.projection_sink
2256                            .signal_terminal_error(
2257                                &session_id,
2258                                code.clone(),
2259                                "invalid durable image identity",
2260                            )
2261                            .await?;
2262                        Ok(ObservationOutcome::Terminal { code })
2263                    }
2264                    Some(RealtimeUserContentApplyOutcome::RejectedUnmaterializedPredecessor {
2265                        ..
2266                    }) => {
2267                        let code = LiveAdapterErrorCode::ConfigRejected {
2268                            reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputRequiresCommit,
2269                        };
2270                        self.projection_sink
2271                            .signal_terminal_error(
2272                                &session_id,
2273                                code.clone(),
2274                                "image predecessor is not canonical",
2275                            )
2276                            .await?;
2277                        Ok(ObservationOutcome::Terminal { code })
2278                    }
2279                    Some(RealtimeUserContentApplyOutcome::RejectedConflict { .. }) => {
2280                        let code = LiveAdapterErrorCode::ConfigRejected {
2281                            reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputIdempotencyConflict,
2282                        };
2283                        self.projection_sink
2284                            .signal_terminal_error(
2285                                &session_id,
2286                                code.clone(),
2287                                "image idempotency conflict after provider acknowledgement",
2288                            )
2289                            .await?;
2290                        Ok(ObservationOutcome::Terminal { code })
2291                    }
2292                    None => Ok(ObservationOutcome::TranscriptAppended),
2293                }
2294            }
2295
2296            (
2297                ObservationRouting::DispatchToolCall { .. },
2298                LiveAdapterObservation::ToolCallRequested {
2299                    provider_call_id,
2300                    tool_name,
2301                    arguments,
2302                },
2303            ) => {
2304                self.dispatch_tool_call(channel_id, provider_call_id, tool_name, arguments.clone())
2305                    .await
2306            }
2307
2308            (
2309                ObservationRouting::SignalInterrupt,
2310                LiveAdapterObservation::TurnInterrupted { response_id },
2311            ) => {
2312                self.projection_sink
2313                    .signal_turn_interrupt(&session_id, response_id.as_deref())
2314                    .await?;
2315                Ok(ObservationOutcome::InterruptSignalled)
2316            }
2317
2318            (
2319                ObservationRouting::TerminalError,
2320                LiveAdapterObservation::Error { code, message },
2321            ) => {
2322                self.projection_sink
2323                    .signal_terminal_error(&session_id, code.clone(), message)
2324                    .await?;
2325                Ok(ObservationOutcome::Terminal { code: code.clone() })
2326            }
2327
2328            // R5-9: scoped per-command rejection. The channel must
2329            // survive — the client sent a typed-input variant the
2330            // provider local-guard rejected (e.g.
2331            // `LiveInputChunk::Image` against an audio-only model),
2332            // and the next valid command should land on the same
2333            // channel. We deliberately do NOT touch host channel
2334            // state (status, retire_at, adapter) and do NOT call
2335            // `signal_terminal_error` on the projection sink — those
2336            // are the terminal-only obligations.
2337            (
2338                ObservationRouting::CommandRejection,
2339                LiveAdapterObservation::CommandRejected { code, message },
2340            ) => Ok(ObservationOutcome::CommandRejected {
2341                code: code.clone(),
2342                message: message.clone(),
2343            }),
2344
2345            // Routing said AppendTranscript but the observation kind didn't
2346            // match any of the variants we handle above. Fall through as a
2347            // no-op so adding a new transcript-shaped variant doesn't panic
2348            // here before the projection knows how to handle it.
2349            (ObservationRouting::AppendTranscript, _) => Ok(ObservationOutcome::Noop),
2350
2351            // Routing/observation mismatches that should not occur — return
2352            // a no-op outcome so the adapter pump keeps going. (The
2353            // `classify_observation` function is the single source of truth;
2354            // any mismatch here is a bug in classification, not a runtime
2355            // condition we should panic on.)
2356            _ => Ok(ObservationOutcome::Noop),
2357        }
2358    }
2359
2360    async fn dispatch_tool_call(
2361        &self,
2362        channel_id: &LiveChannelId,
2363        provider_call_id: &ToolCallId,
2364        tool_name: &ToolName,
2365        arguments: serde_json::Value,
2366    ) -> Result<ObservationOutcome, LiveAdapterHostError> {
2367        let dispatcher = match self.load_dispatcher() {
2368            Some(d) => d,
2369            None => {
2370                // P2#2: without a dispatcher, the provider would otherwise
2371                // wait forever for a tool result that will never arrive,
2372                // deadlocking the live session until the provider's own
2373                // timeout (or never). Send a typed `SubmitToolError` so the
2374                // provider can complete the response with an error and
2375                // unstick the live turn. The typed
2376                // `ObservationOutcome::ToolCallSkipped { NoDispatcher }`
2377                // return is preserved so the host's audit trail still shows
2378                // the miswiring. Best-effort: if the adapter is not attached
2379                // (no channel adapter yet), swallow the error rather than
2380                // poisoning the projection — the original miswiring is the
2381                // root cause and is already audited via the outcome.
2382                let _ = self
2383                    .submit_tool_error(
2384                        channel_id,
2385                        provider_call_id.clone(),
2386                        "live tool dispatcher not configured".to_string(),
2387                    )
2388                    .await;
2389                return Ok(ObservationOutcome::ToolCallSkipped {
2390                    provider_call_id: provider_call_id.0.clone(),
2391                    tool_name: tool_name.to_string(),
2392                    reason: ToolDispatchSkipReason::NoDispatcher,
2393                });
2394            }
2395        };
2396
2397        let session_id = self.channel_session(channel_id).await?;
2398        let call = ToolCall::new(provider_call_id.0.clone(), tool_name.to_string(), arguments);
2399
2400        // Race the dispatcher future against the configured timeout. The
2401        // deadline is mandatory — `LiveAdapterHost::new` initializes it to
2402        // `DEFAULT_LIVE_TOOL_TIMEOUT` — so a stuck dispatcher can never hold
2403        // a live provider's turn unboundedly.
2404        let dispatch_call = dispatcher.dispatch_live_tool_call(&session_id, call);
2405        let timeout = self.tool_timeout;
2406        let dispatch_result = match tokio::time::timeout(timeout, dispatch_call).await {
2407            Ok(result) => result,
2408            Err(_elapsed) => {
2409                // D281: notify the adapter so the provider can unblock its
2410                // turn — but carry the typed `LiveToolDispatchTimeout` fact
2411                // rather than fabricating a parseable prose string. The
2412                // typed `ObservationOutcome::ToolCallTimedOut` is returned
2413                // to the host's own callers; the adapter-facing submission
2414                // renders the typed fact only at the meerkat-core
2415                // `SubmitToolError { error: String }` seam edge (that wire
2416                // field is owned by meerkat-core and cannot be made fully
2417                // typed from this crate).
2418                self.submit_tool_dispatch_timeout(
2419                    channel_id,
2420                    provider_call_id.clone(),
2421                    LiveToolDispatchTimeout::new(timeout),
2422                )
2423                .await?;
2424                return Ok(ObservationOutcome::ToolCallTimedOut {
2425                    provider_call_id: provider_call_id.0.clone(),
2426                    tool_name: tool_name.to_string(),
2427                    timeout,
2428                });
2429            }
2430        };
2431        match dispatch_result {
2432            Ok(outcome) => {
2433                let live_result =
2434                    tool_result_from_dispatch(provider_call_id.clone(), outcome.result);
2435                self.submit_tool_result(channel_id, live_result).await?;
2436            }
2437            Err(err) => {
2438                self.submit_tool_error(channel_id, provider_call_id.clone(), err.to_string())
2439                    .await?;
2440            }
2441        }
2442
2443        Ok(ObservationOutcome::ToolCallDispatched {
2444            provider_call_id: provider_call_id.0.clone(),
2445            tool_name: tool_name.to_string(),
2446        })
2447    }
2448
2449    /// Helper: fetch the live adapter for a channel, optionally enforcing
2450    /// `LiveAdapterStatus::accepts_commands()` (F31).
2451    async fn adapter_for(
2452        &self,
2453        channel_id: &LiveChannelId,
2454        require_ready: bool,
2455    ) -> Result<Arc<dyn LiveAdapter>, LiveAdapterHostError> {
2456        let inner = self.inner.lock().await;
2457        let channel = inner
2458            .channels
2459            .get(channel_id)
2460            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2461        if channel.retire_at.is_some() {
2462            // Channel is in post-close grace: status reads still work, but
2463            // commands/observations target a removed adapter.
2464            return Err(LiveAdapterHostError::ChannelNotReady(
2465                channel_id.clone(),
2466                channel.status.clone(),
2467            ));
2468        }
2469        if require_ready && !channel.status.accepts_commands() {
2470            return Err(LiveAdapterHostError::ChannelNotReady(
2471                channel_id.clone(),
2472                channel.status.clone(),
2473            ));
2474        }
2475        let adapter = channel
2476            .adapter
2477            .as_ref()
2478            .ok_or_else(|| LiveAdapterHostError::NoAdapter(channel_id.clone()))?;
2479        Ok(Arc::clone(adapter))
2480    }
2481
2482    /// CC1 / R11 wire-signal: enqueue a synthetic terminal `Error`
2483    /// observation on the channel and close the underlying adapter.
2484    ///
2485    /// The synthetic observation is queued on a per-channel one-shot slot
2486    /// that [`Self::next_observation_raw`] inspects before polling the
2487    /// adapter. The next read by the WS pump returns the synthetic Error,
2488    /// which the pump routes through generated close authority before
2489    /// [`Self::apply_observation`] can yield [`ObservationOutcome::Terminal`].
2490    ///
2491    /// Use this when the runtime decides a channel must die for a typed
2492    /// reason that the provider adapter never produced (e.g. model swap on
2493    /// `config/patch`, where the OpenAI realtime adapter cannot rebind the
2494    /// model in-place). Without this seam the host can lose the typed terminal
2495    /// cause before generated close authority records terminality.
2496    ///
2497    /// `message` defaults from the typed code: `ConfigRejected { reason }`
2498    /// uses `reason` directly, other variants fall back to the serde tag.
2499    /// The pending obs is enqueued FIRST, then a close observation is reserved
2500    /// for generated close authority. Callers commit host close cleanup only
2501    /// after that authority accepts the observation.
2502    #[cfg(test)]
2503    pub(crate) async fn signal_terminal_error(
2504        &self,
2505        channel_id: &LiveChannelId,
2506        code: LiveAdapterErrorCode,
2507    ) -> Result<(), LiveAdapterHostError> {
2508        let observation = self
2509            .signal_terminal_error_observed(channel_id, code)
2510            .await?;
2511        self.prepare_channel_physical_close(&observation).await?;
2512        let authority = self
2513            .close_commit_authority_from_generated_test_machine(&observation)
2514            .await?;
2515        self.commit_channel_close_observation(&observation, &authority)
2516            .await
2517    }
2518
2519    pub async fn signal_terminal_error_observed(
2520        &self,
2521        channel_id: &LiveChannelId,
2522        code: LiveAdapterErrorCode,
2523    ) -> Result<LiveChannelCloseObservation, LiveAdapterHostError> {
2524        let message = match &code {
2525            // R5-2: `reason` is a typed `LiveConfigRejectionReason`; render
2526            // via `Display` (which preserves the human-readable swap/text
2527            // contract callers previously read out of the `String` reason).
2528            LiveAdapterErrorCode::ConfigRejected { reason } => reason.to_string(),
2529            other => format!("{other:?}"),
2530        };
2531        let synthetic = LiveAdapterObservation::Error {
2532            code: code.clone(),
2533            message: message.clone(),
2534        };
2535
2536        // R5-3: deliver the synthetic Error THROUGH the adapter's own
2537        // observation channel so an in-flight `next_observation()`
2538        // future on the WS pump returns the typed event before the
2539        // close-driven `None`. We do this in two complementary phases:
2540        //
2541        // 1. Stage the synthetic in `pending_synthetic_obs` so a
2542        //    subsequent `next_observation_raw` call (after channel
2543        //    close) still returns the typed event — adapters whose
2544        //    `inject_observation` is a no-op (test stubs) rely on this
2545        //    fallback path.
2546        // 2. Call the adapter's `inject_observation` to push the
2547        //    synthetic onto the live observation stream — this is what
2548        //    actually unsticks an awaiting WS pump on a real adapter.
2549        //
2550        // Order matters: stage FIRST, inject SECOND, reserve close evidence
2551        // THIRD. If injection fails (adapter already torn down), the fallback in
2552        // `next_observation_raw`'s pending check still surfaces the
2553        // typed event.
2554        let adapter = {
2555            let mut inner = self.inner.lock().await;
2556            let channel = inner
2557                .channels
2558                .get_mut(channel_id)
2559                .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2560            channel.pending_synthetic_obs = Some(synthetic.clone());
2561            channel.adapter.clone()
2562        };
2563        if let Some(adapter) = adapter {
2564            // Best-effort: a half-closed adapter cannot accept the
2565            // injection, but the `pending_synthetic_obs` fallback above
2566            // covers that case.
2567            let _ = adapter.inject_observation(synthetic).await;
2568        }
2569        self.reserve_channel_close_observation(channel_id).await
2570    }
2571
2572    pub async fn reserve_channel_close_observation(
2573        &self,
2574        channel_id: &LiveChannelId,
2575    ) -> Result<LiveChannelCloseObservation, LiveAdapterHostError> {
2576        let mut inner = self.inner.lock().await;
2577        Self::reap_retired_locked(&mut inner);
2578        let channel = inner
2579            .channels
2580            .get_mut(channel_id)
2581            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2582        channel.close_observation_sequence = channel.close_observation_sequence.saturating_add(1);
2583        LiveChannelCloseObservation::from_host_close_observation(
2584            channel_id.as_str().to_owned(),
2585            channel.close_observation_sequence,
2586        )
2587        .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2588    }
2589
2590    pub async fn commit_channel_close_observation(
2591        &self,
2592        observation: &LiveChannelCloseObservation,
2593        authority: &LiveChannelCloseCommitAuthority,
2594    ) -> Result<(), LiveAdapterHostError> {
2595        if authority.channel_id() != observation.channel_id()
2596            || authority.close_sequence() != observation.close_sequence()
2597        {
2598            return Err(LiveAdapterHostError::CloseNotAuthorized);
2599        }
2600        // G42: keep the channel reachable for `live/status` until the TTL
2601        // elapses. We unbind the adapter (releasing transport resources) and
2602        // mark the channel as `Closed`, but leave the entry in `channels`
2603        // so post-close reads can report the terminal status. This commit is
2604        // called only after generated close authority accepts the typed close
2605        // observation.
2606        let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2607        {
2608            let mut inner = self.inner.lock().await;
2609            Self::reap_retired_locked(&mut inner);
2610            let channel = inner
2611                .channels
2612                .get_mut(&channel_id)
2613                .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2614            if !channel.physical_close_confirmed || channel.adapter.is_some() {
2615                return Err(LiveAdapterHostError::CloseNotAuthorized);
2616            }
2617            // Consume and commit under the same host lock as the physical
2618            // proof check. An adapter reattachment cannot slip between proof
2619            // validation and terminal cache publication.
2620            authority.consume_once()?;
2621            channel.status = LiveAdapterStatus::Closed;
2622            channel.retire_at = Some(std::time::Instant::now() + CLOSED_CHANNEL_TTL);
2623        }
2624        Ok(())
2625    }
2626
2627    /// Establish transport-mechanical absence before generated close
2628    /// terminality is allowed to commit.
2629    ///
2630    /// Adapter close failure keeps both the adapter handle and the generated
2631    /// active-channel binding discoverable, so a retry cannot misread
2632    /// `ChannelNotFound` as proof of physical cleanup. A successful close
2633    /// drops the exact adapter only if no concurrent replacement occurred.
2634    pub async fn prepare_channel_physical_close(
2635        &self,
2636        observation: &LiveChannelCloseObservation,
2637    ) -> Result<(), LiveAdapterHostError> {
2638        let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2639        let adapter = {
2640            let mut inner = self.inner.lock().await;
2641            Self::reap_retired_locked(&mut inner);
2642            let channel = inner
2643                .channels
2644                .get(&channel_id)
2645                .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2646            if channel.physical_close_confirmed && channel.adapter.is_none() {
2647                return Ok(());
2648            }
2649            channel
2650                .adapter
2651                .as_ref()
2652                .map(Arc::clone)
2653                .ok_or_else(|| LiveAdapterHostError::NoAdapter(channel_id.clone()))?
2654        };
2655
2656        adapter.close().await?;
2657
2658        let mut inner = self.inner.lock().await;
2659        let channel = inner
2660            .channels
2661            .get_mut(&channel_id)
2662            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2663        match channel.adapter.as_ref() {
2664            Some(current) if Arc::ptr_eq(current, &adapter) => {
2665                channel.adapter = None;
2666                channel.physical_close_confirmed = true;
2667                Ok(())
2668            }
2669            None if channel.physical_close_confirmed => Ok(()),
2670            _ => Err(LiveAdapterHostError::CloseNotAuthorized),
2671        }
2672    }
2673
2674    #[cfg(test)]
2675    pub(crate) async fn close_commit_authority_from_generated_test_machine(
2676        &self,
2677        observation: &LiveChannelCloseObservation,
2678    ) -> Result<LiveChannelCloseCommitAuthority, LiveAdapterHostError> {
2679        let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2680        let session_id = self.channel_session(&channel_id).await?;
2681        Ok(
2682            LiveChannelCloseCommitAuthority::from_generated_test_machine(
2683                &session_id,
2684                &channel_id,
2685                observation.close_sequence(),
2686            ),
2687        )
2688    }
2689
2690    #[cfg(test)]
2691    pub(crate) async fn close_channel_observed_with_generated_test_machine_authority(
2692        &self,
2693        channel_id: &LiveChannelId,
2694    ) -> Result<LiveChannelCloseObservation, LiveAdapterHostError> {
2695        let observation = self.reserve_channel_close_observation(channel_id).await?;
2696        self.prepare_channel_physical_close(&observation).await?;
2697        let authority = self
2698            .close_commit_authority_from_generated_test_machine(&observation)
2699            .await?;
2700        self.commit_channel_close_observation(&observation, &authority)
2701            .await?;
2702        Ok(observation)
2703    }
2704
2705    #[cfg(test)]
2706    pub(crate) async fn close_channel_with_generated_test_machine_authority(
2707        &self,
2708        channel_id: &LiveChannelId,
2709    ) -> Result<(), LiveAdapterHostError> {
2710        self.close_channel_observed_with_generated_test_machine_authority(channel_id)
2711            .await
2712            .map(|_| ())
2713    }
2714
2715    #[cfg(test)]
2716    pub(crate) async fn status_commit_authority_from_generated_test_machine(
2717        &self,
2718        observation: &LiveChannelStatusObservation,
2719    ) -> Result<LiveChannelStatusCommitAuthority, LiveAdapterHostError> {
2720        let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2721        let session_id = self.channel_session(&channel_id).await?;
2722        let channel_id_string = channel_id.as_str().to_owned();
2723        let (status, degradation_reason, degradation_detail) =
2724            generated_test_live_channel_status(observation.status());
2725        let mut authority = generated_test_machine_for_registered_session(&session_id);
2726        meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
2727            &mut authority,
2728            meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::ResolveLiveOpenAdmission {
2729                session_id: session_id.to_string(),
2730                channel_id: channel_id_string.clone(),
2731                llm_identity: generated_test_llm_identity(),
2732            },
2733        )
2734        .expect("generated MeerkatMachine live-open admission");
2735        let transition = meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
2736            &mut authority,
2737            meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::RecordLiveChannelStatus {
2738                channel_id: channel_id_string.clone(),
2739                status,
2740                status_observation_sequence: observation.observation_sequence(),
2741                degradation_reason,
2742                degradation_detail: degradation_detail.clone(),
2743            },
2744        )
2745        .expect("generated MeerkatMachine live-status result");
2746        assert!(
2747            transition.effects().iter().any(|effect| matches!(
2748                effect,
2749                meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineEffect::LiveChannelStatusResolved {
2750                    channel_id: effect_channel_id,
2751                    status: effect_status,
2752                    status_observation_sequence,
2753                    ..
2754                } if *effect_channel_id == channel_id_string
2755                    && *effect_status == status
2756                    && *status_observation_sequence == observation.observation_sequence()
2757            )),
2758            "generated live-status result effect"
2759        );
2760        Ok(LiveChannelStatusCommitAuthority::from_generated_parts(
2761            channel_id_string,
2762            observation.observation_sequence(),
2763        ))
2764    }
2765
2766    #[cfg(test)]
2767    pub(crate) async fn commit_status_with_generated_test_machine_authority(
2768        &self,
2769        channel_id: &LiveChannelId,
2770        status: LiveAdapterStatus,
2771    ) -> Result<LiveChannelStatusObservation, LiveAdapterHostError> {
2772        let observation = self
2773            .reserve_channel_status_observation(channel_id, status)
2774            .await?;
2775        let authority = self
2776            .status_commit_authority_from_generated_test_machine(&observation)
2777            .await?;
2778        self.commit_channel_status_observation(&observation, &authority)
2779            .await?;
2780        Ok(observation)
2781    }
2782
2783    /// Return the host's generated-committed adapter-status cache.
2784    ///
2785    /// Crate-internal transport cleanup and unit tests may inspect this cache
2786    /// directly. Public surfaces must use [`Self::channel_status_observation`]
2787    /// and submit the observation to generated MeerkatMachine authority before
2788    /// projecting a result to callers.
2789    pub(crate) async fn channel_status(
2790        &self,
2791        channel_id: &LiveChannelId,
2792    ) -> Result<LiveAdapterStatus, LiveAdapterHostError> {
2793        let mut inner = self.inner.lock().await;
2794        Self::reap_retired_locked(&mut inner);
2795        inner
2796            .channels
2797            .get(channel_id)
2798            .map(|ch| ch.status.clone())
2799            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2800    }
2801
2802    pub async fn channel_status_observation(
2803        &self,
2804        channel_id: &LiveChannelId,
2805    ) -> Result<LiveChannelStatusObservation, LiveAdapterHostError> {
2806        // This sequence is host-minted observation evidence only. Gaps from
2807        // failed or abandoned generated submissions are non-semantic; the
2808        // generated machine owns the accepted sequence it stores in
2809        // `live_channel_status_observation_sequence_by_channel`.
2810        let mut inner = self.inner.lock().await;
2811        Self::reap_retired_locked(&mut inner);
2812        let channel = inner
2813            .channels
2814            .get_mut(channel_id)
2815            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2816        channel.status_observation_sequence = channel.status_observation_sequence.saturating_add(1);
2817        LiveChannelStatusObservation::from_host_status_observation(
2818            channel_id.as_str().to_owned(),
2819            channel.status.clone(),
2820            channel.status_observation_sequence,
2821        )
2822        .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2823    }
2824
2825    pub async fn reserve_channel_status_observation(
2826        &self,
2827        channel_id: &LiveChannelId,
2828        status: LiveAdapterStatus,
2829    ) -> Result<LiveChannelStatusObservation, LiveAdapterHostError> {
2830        if status.is_terminal() {
2831            return Err(LiveAdapterHostError::StatusNotAuthorized);
2832        }
2833        let mut inner = self.inner.lock().await;
2834        Self::reap_retired_locked(&mut inner);
2835        let channel = inner
2836            .channels
2837            .get_mut(channel_id)
2838            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2839        if channel.retire_at.is_some() {
2840            return Err(LiveAdapterHostError::ChannelNotReady(
2841                channel_id.clone(),
2842                channel.status.clone(),
2843            ));
2844        }
2845        // This sequence is host-minted observation evidence only. It does not
2846        // become status truth unless generated MeerkatMachine authority accepts
2847        // it and returns a commit handoff.
2848        channel.status_observation_sequence = channel.status_observation_sequence.saturating_add(1);
2849        LiveChannelStatusObservation::from_host_status_observation(
2850            channel_id.as_str().to_owned(),
2851            status,
2852            channel.status_observation_sequence,
2853        )
2854        .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2855    }
2856
2857    pub async fn commit_channel_status_observation(
2858        &self,
2859        observation: &LiveChannelStatusObservation,
2860        authority: &LiveChannelStatusCommitAuthority,
2861    ) -> Result<(), LiveAdapterHostError> {
2862        authority.consume_once()?;
2863        if authority.channel_id() != observation.channel_id()
2864            || authority.status_observation_sequence() != observation.observation_sequence()
2865        {
2866            return Err(LiveAdapterHostError::StatusNotAuthorized);
2867        }
2868        if observation.status().is_terminal() {
2869            return Err(LiveAdapterHostError::StatusNotAuthorized);
2870        }
2871        let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2872        let mut inner = self.inner.lock().await;
2873        Self::reap_retired_locked(&mut inner);
2874        let channel = inner
2875            .channels
2876            .get_mut(&channel_id)
2877            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2878        if channel.retire_at.is_some() {
2879            return Err(LiveAdapterHostError::ChannelNotReady(
2880                channel_id,
2881                channel.status.clone(),
2882            ));
2883        }
2884        channel.status = observation.status().clone();
2885        Ok(())
2886    }
2887
2888    pub async fn channel_session(
2889        &self,
2890        channel_id: &LiveChannelId,
2891    ) -> Result<SessionId, LiveAdapterHostError> {
2892        let inner = self.inner.lock().await;
2893        inner
2894            .channels
2895            .get(channel_id)
2896            .map(|ch| ch.session_id.clone())
2897            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2898    }
2899
2900    /// Lower a transport-local barge-in (e.g. local VAD detecting user speech
2901    /// while output audio is still queued) into the canonical interrupt seam
2902    /// (D223).
2903    ///
2904    /// This routes through the *same* [`LiveProjectionSink::signal_turn_interrupt`]
2905    /// path that adapter-observed [`LiveAdapterObservation::TurnInterrupted`]
2906    /// barge-ins use, so a transport-discarded output-audio queue is a typed
2907    /// interrupt fact the session observes — not a silent
2908    /// `tracing`-and-discard. The transport has no provider response id for a
2909    /// locally-detected barge-in, so `response_id` is `None` (the same shape
2910    /// the user-facing `live/interrupt` RPC uses).
2911    pub async fn signal_transport_barge_in(
2912        &self,
2913        channel_id: &LiveChannelId,
2914    ) -> Result<(), LiveAdapterHostError> {
2915        let session_id = self.channel_session(channel_id).await?;
2916        self.projection_sink
2917            .signal_turn_interrupt(&session_id, None)
2918            .await?;
2919        Ok(())
2920    }
2921
2922    /// Lower a transport-local output-audio delivery degradation into the
2923    /// canonical host signal seam (K16).
2924    ///
2925    /// This routes through the *same* [`LiveProjectionSink`] signal path that
2926    /// [`Self::signal_transport_barge_in`] uses, so dropped output-audio
2927    /// packets (e.g. RTP pacing-queue backpressure) are a typed,
2928    /// session-observable delivery fact — not a transport-local counter whose
2929    /// only reader is a test. `dropped` carries the cumulative drop count for
2930    /// the channel.
2931    pub async fn signal_output_audio_degraded(
2932        &self,
2933        channel_id: &LiveChannelId,
2934        dropped: u64,
2935    ) -> Result<(), LiveAdapterHostError> {
2936        let session_id = self.channel_session(channel_id).await?;
2937        self.projection_sink
2938            .signal_output_audio_degraded(&session_id, dropped)
2939            .await?;
2940        Ok(())
2941    }
2942
2943    pub fn classify_observation(observation: &LiveAdapterObservation) -> ObservationRouting {
2944        match observation {
2945            LiveAdapterObservation::Ready => {
2946                ObservationRouting::UpdateStatus(LiveAdapterStatus::Ready)
2947            }
2948            LiveAdapterObservation::UserTranscriptFinal { .. } => {
2949                ObservationRouting::AppendTranscript
2950            }
2951            LiveAdapterObservation::AssistantTextDelta { .. } => {
2952                ObservationRouting::AppendTranscript
2953            }
2954            // T5/T6: spoken-transcript deltas route to the transcript lane,
2955            // distinct from display-text deltas above.
2956            LiveAdapterObservation::AssistantTranscriptDelta { .. } => {
2957                ObservationRouting::AppendTranscript
2958            }
2959            LiveAdapterObservation::AssistantAudioChunk { .. } => ObservationRouting::Noop,
2960            LiveAdapterObservation::AssistantTranscriptFinal { .. } => {
2961                ObservationRouting::AppendTranscript
2962            }
2963            LiveAdapterObservation::AssistantTranscriptTruncated { .. } => {
2964                ObservationRouting::AppendTranscript
2965            }
2966            // P1#2: structured realtime events flow through the typed
2967            // realtime-transcript seam so the session layer owns idempotent
2968            // ordering. Without this route, ItemObserved / ItemSkipped /
2969            // AssistantTurnCompleted / AssistantTurnInterrupted dropped to
2970            // `Noop` and bypassed canonical staging.
2971            LiveAdapterObservation::RealtimeTranscript { .. } => {
2972                ObservationRouting::AppendRealtimeTranscript
2973            }
2974            // Canonical persistence is owned by the immediately preceding
2975            // RealtimeTranscript event. This redacted receipt exists only as
2976            // a public transport ordering barrier.
2977            LiveAdapterObservation::UserContentCommitted { .. } => ObservationRouting::Noop,
2978            LiveAdapterObservation::ToolCallRequested {
2979                provider_call_id,
2980                tool_name,
2981                ..
2982            } => ObservationRouting::DispatchToolCall {
2983                // `ObservationRouting::DispatchToolCall` carries `String`
2984                // routing keys; project the typed ids to their wire strings.
2985                provider_call_id: provider_call_id.0.clone(),
2986                tool_name: tool_name.to_string(),
2987            },
2988            LiveAdapterObservation::TurnInterrupted { .. } => ObservationRouting::SignalInterrupt,
2989            LiveAdapterObservation::TurnCompleted { .. } => ObservationRouting::AppendTranscript,
2990            LiveAdapterObservation::StatusChanged { status } => {
2991                ObservationRouting::UpdateStatus(status.clone())
2992            }
2993            LiveAdapterObservation::Error { .. } => ObservationRouting::TerminalError,
2994            // R5-9: scoped per-command rejection — channel survives, WS
2995            // pump forwards JSON and continues. Distinct routing so the
2996            // typed taxonomy at the wire boundary
2997            // (`Error` → terminal, `CommandRejected` → scoped) maps
2998            // 1:1 onto host outcomes.
2999            LiveAdapterObservation::CommandRejected { .. } => ObservationRouting::CommandRejection,
3000            _ => ObservationRouting::Noop,
3001        }
3002    }
3003
3004    fn observation_requires_generated_close(observation: &LiveAdapterObservation) -> bool {
3005        match observation {
3006            LiveAdapterObservation::Error { .. } => true,
3007            LiveAdapterObservation::StatusChanged { status } => status.is_terminal(),
3008            _ => false,
3009        }
3010    }
3011
3012    /// Read-only transport projection of generated close cleanup.
3013    ///
3014    /// `Closed` can be written only by [`Self::commit_channel_close_observation`],
3015    /// which consumes a generated close commit handoff. Transports use this to
3016    /// avoid requesting a second close decision when a runtime path has already
3017    /// committed terminality before the staged terminal observation is drained.
3018    pub(crate) async fn generated_close_has_committed(
3019        &self,
3020        channel_id: &LiveChannelId,
3021    ) -> Result<bool, LiveAdapterHostError> {
3022        self.channel_status(channel_id)
3023            .await
3024            .map(|status| status.is_terminal())
3025    }
3026
3027    pub async fn next_snapshot_version(
3028        &self,
3029        channel_id: &LiveChannelId,
3030    ) -> Result<u64, LiveAdapterHostError> {
3031        let mut inner = self.inner.lock().await;
3032        let channel = inner
3033            .channels
3034            .get_mut(channel_id)
3035            .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
3036        channel.snapshot_version += 1;
3037        Ok(channel.snapshot_version)
3038    }
3039
3040    pub async fn active_channels(&self) -> Vec<LiveChannelId> {
3041        let mut inner = self.inner.lock().await;
3042        Self::reap_retired_locked(&mut inner);
3043        // Transport-resource view: exclude channels in their post-close TTL
3044        // retention window. Retained-closed entries remain in `channels` so
3045        // the reap path (G1) and post-close status reads (G42) keep working,
3046        // but they no longer have an adapter handoff target. Public
3047        // lifecycle/admission callers must still require generated live-open
3048        // authority; this list is only the host's currently handoff-capable
3049        // transport cache.
3050        inner
3051            .channels
3052            .iter()
3053            .filter(|(_, ch)| ch.retire_at.is_none())
3054            .map(|(id, _)| id.clone())
3055            .collect()
3056    }
3057
3058    /// Reap channels whose post-close TTL has elapsed.
3059    fn reap_retired_locked(inner: &mut HostInner) {
3060        let now = std::time::Instant::now();
3061        let to_drop: Vec<LiveChannelId> = inner
3062            .channels
3063            .iter()
3064            .filter_map(|(id, ch)| match ch.retire_at {
3065                Some(deadline) if deadline <= now => Some(id.clone()),
3066                _ => None,
3067            })
3068            .collect();
3069        for id in to_drop {
3070            if let Some(ch) = inner.channels.shift_remove(&id) {
3071                // G1 (P1): only clear the reverse mapping if it still
3072                // points at the channel being reaped. After close + rebind,
3073                // `by_session[S]` already names the new channel, and an
3074                // unconditional remove would strand the rebound channel
3075                // (subsequent opens for S would then create duplicates).
3076                if inner
3077                    .by_session
3078                    .get(&ch.session_id)
3079                    .is_some_and(|current| current == &id)
3080                {
3081                    inner.by_session.remove(&ch.session_id);
3082                }
3083            }
3084        }
3085    }
3086}
3087
3088/// Helper: project a typed [`ToolResult`] from the agent dispatcher into
3089/// the seam-owned [`LiveToolResult`]. The two carry the same structured
3090/// content shape (`Vec<ContentBlock>`) so tool-result fidelity (text, image,
3091/// video) is preserved across the seam (closes E30 at this layer).
3092fn tool_result_from_dispatch(call_id: ToolCallId, result: ToolResult) -> LiveToolResult {
3093    LiveToolResult {
3094        // `LiveToolResult.call_id` is the meerkat-core seam edge and remains
3095        // a bare `String`; project the typed id to the wire string here.
3096        call_id,
3097        content: result.content,
3098        is_error: result.is_error,
3099    }
3100}
3101
3102#[cfg(test)]
3103#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3104mod tests {
3105    use super::*;
3106    use async_trait::async_trait;
3107    use meerkat_core::live_adapter::{
3108        LiveAdapterError, LiveAdapterErrorCode, LiveAdapterObservation, LiveDegradationReason,
3109    };
3110    use meerkat_core::ops::ToolDispatchOutcome;
3111    use meerkat_core::types::{StopReason, Usage};
3112    use std::sync::Mutex as StdMutex;
3113
3114    fn test_session_id() -> SessionId {
3115        SessionId::new()
3116    }
3117
3118    // -- Tool timeout default (G6 regression, type-enforced) --
3119
3120    /// G6 (P2): the per-tool-call dispatch deadline is mandatory and
3121    /// type-enforced — [`LiveAdapterHost::new`] initializes it to
3122    /// [`DEFAULT_LIVE_TOOL_TIMEOUT`], so no surface can construct a host
3123    /// with an unbounded await. The builder is an override, not an opt-in.
3124    #[test]
3125    fn tool_timeout_defaults_to_canonical_value_and_builder_overrides_it() {
3126        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3127        assert_eq!(host.tool_timeout(), DEFAULT_LIVE_TOOL_TIMEOUT);
3128
3129        let override_timeout = Duration::from_secs(7);
3130        let host =
3131            LiveAdapterHost::new(Arc::new(NoOpProjectionSink)).with_tool_timeout(override_timeout);
3132        assert_eq!(host.tool_timeout(), override_timeout);
3133        // Pin the canonical default value so accidental constant drift
3134        // is caught here rather than at integration time.
3135        assert_eq!(DEFAULT_LIVE_TOOL_TIMEOUT, Duration::from_secs(30));
3136    }
3137
3138    // -- R5-1 (P2 dogma): projection sink mandatory at construction --
3139
3140    /// R5-1 (P2 dogma): the projection sink is mandatory at host
3141    /// construction. A successful `ObservationOutcome::TranscriptAppended`
3142    /// must imply a real semantic owner received the projection — the
3143    /// previous `Option<Arc<dyn LiveProjectionSink>>` permitted
3144    /// "successful append with nobody to project to," which violates the
3145    /// dogma that success outcomes are truthful completion.
3146    ///
3147    /// This test pins:
3148    ///   1. A host built with the production-shape `RecordingProjectionSink`
3149    ///      routes a `UserTranscriptFinal` observation to the sink and
3150    ///      returns `TranscriptAppended` — the sink received exactly one
3151    ///      append, confirming the success outcome corresponds to a real
3152    ///      projection.
3153    ///   2. A host built with the explicit-opt-out `NoOpProjectionSink`
3154    ///      still classifies the same observation and returns
3155    ///      `TranscriptAppended` (the no-op sink swallows the append by
3156    ///      design). The intent of "dropping projections on the floor" is
3157    ///      now visible in the call site (`Arc::new(NoOpProjectionSink)`)
3158    ///      rather than hidden in `projection_sink: None`.
3159    #[tokio::test]
3160    async fn projection_sink_is_mandatory_at_construction() {
3161        // Production shape: real sink routes the observation.
3162        let recording = Arc::new(RecordingProjectionSink::default());
3163        let host = LiveAdapterHost::new(Arc::clone(&recording) as _);
3164        let session = test_session_id();
3165        let ch = host
3166            .open_channel_with_generated_test_machine_authority(session.clone())
3167            .await
3168            .unwrap();
3169        let obs = LiveAdapterObservation::UserTranscriptFinal {
3170            provider_item_id: Some("item-1".into()),
3171            previous_item_id: None,
3172            content_index: Some(0),
3173            text: "hello".into(),
3174        };
3175        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
3176        assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
3177        assert_eq!(
3178            recording.user_transcripts.lock().unwrap().len(),
3179            1,
3180            "production-shape host must route user transcripts to the sink"
3181        );
3182
3183        // Test opt-out shape: NoOpProjectionSink is the *explicit* way to
3184        // request "drop projections on the floor". The success outcome is
3185        // still TranscriptAppended (the routing happened; the sink chose
3186        // to swallow it), but the lack of a semantic owner is now visible
3187        // at the call site rather than hidden in `Option<_>`.
3188        let noop_host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3189        let session2 = test_session_id();
3190        let ch2 = noop_host
3191            .open_channel_with_generated_test_machine_authority(session2)
3192            .await
3193            .unwrap();
3194        let outcome2 = noop_host.apply_observation(&ch2, &obs).await.unwrap();
3195        assert!(matches!(outcome2, ObservationOutcome::TranscriptAppended));
3196    }
3197
3198    // -- Channel lifecycle --
3199
3200    #[tokio::test]
3201    async fn open_channel_returns_unique_ids() {
3202        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3203        let s1 = test_session_id();
3204        let s2 = test_session_id();
3205        let ch1 = host
3206            .open_channel_with_generated_test_machine_authority(s1)
3207            .await
3208            .unwrap();
3209        let ch2 = host
3210            .open_channel_with_generated_test_machine_authority(s2)
3211            .await
3212            .unwrap();
3213        assert_ne!(ch1, ch2);
3214    }
3215
3216    #[tokio::test]
3217    async fn open_channel_ids_are_uuid_shape_not_live_n() {
3218        // G41 regression: the legacy `live_{N}` shape was process-monotonic
3219        // and collided across `rkat-rpc` restarts and across co-tenant host
3220        // instances. Channel ids must now be v4 UUIDs.
3221        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3222        let ch1 = host
3223            .open_channel_with_generated_test_machine_authority(test_session_id())
3224            .await
3225            .unwrap();
3226        let ch2 = host
3227            .open_channel_with_generated_test_machine_authority(test_session_id())
3228            .await
3229            .unwrap();
3230
3231        for ch in [&ch1, &ch2] {
3232            let s = ch.as_str();
3233            assert!(
3234                !s.starts_with("live_"),
3235                "channel id retained legacy `live_N` shape: {s}"
3236            );
3237            // Parse strictly as a v4 UUID — `random_uuid` is the only
3238            // documented constructor; anything else is a regression.
3239            let parsed =
3240                uuid::Uuid::parse_str(s).expect("channel id should be a valid UUID string");
3241            assert_eq!(
3242                parsed.get_version(),
3243                Some(uuid::Version::Random),
3244                "channel id should be a v4 UUID"
3245            );
3246        }
3247        assert_ne!(ch1, ch2);
3248    }
3249
3250    #[tokio::test]
3251    async fn open_channel_starts_in_opening_status() {
3252        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3253        let ch = host
3254            .open_channel_with_generated_test_machine_authority(test_session_id())
3255            .await
3256            .unwrap();
3257        let status = host.channel_status(&ch).await.unwrap();
3258        assert_eq!(status, LiveAdapterStatus::Opening);
3259    }
3260
3261    #[tokio::test]
3262    async fn channel_status_observation_advances_per_channel() {
3263        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3264        let ch = host
3265            .open_channel_with_generated_test_machine_authority(test_session_id())
3266            .await
3267            .unwrap();
3268
3269        let first = host.channel_status_observation(&ch).await.unwrap();
3270        let second = host.channel_status_observation(&ch).await.unwrap();
3271
3272        assert_eq!(first.channel_id(), ch.as_str());
3273        assert_eq!(first.status(), &LiveAdapterStatus::Opening);
3274        assert_eq!(first.observation_sequence(), 1);
3275        assert_eq!(second.observation_sequence(), 2);
3276    }
3277
3278    #[tokio::test]
3279    async fn duplicate_session_binding_rejected() {
3280        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3281        let session_id = test_session_id();
3282        let _ch = host
3283            .open_channel_with_generated_test_machine_authority(session_id.clone())
3284            .await
3285            .unwrap();
3286        let err = host
3287            .open_channel_with_generated_test_machine_authority(session_id.clone())
3288            .await
3289            .unwrap_err();
3290        assert!(matches!(err, LiveAdapterHostError::SessionAlreadyBound(id) if id == session_id));
3291    }
3292
3293    #[tokio::test]
3294    async fn close_channel_marks_closed_and_retains_for_status_reads() {
3295        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3296        let ch = host
3297            .open_channel_with_generated_test_machine_authority(test_session_id())
3298            .await
3299            .unwrap();
3300        host.close_channel_with_generated_test_machine_authority(&ch)
3301            .await
3302            .unwrap();
3303        // G42: post-close status is `Closed`, not `ChannelNotFound`.
3304        let status = host.channel_status(&ch).await.unwrap();
3305        assert_eq!(status, LiveAdapterStatus::Closed);
3306    }
3307
3308    #[tokio::test]
3309    async fn physical_close_failure_retains_discovery_and_blocks_terminal_commit() {
3310        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3311        let ch = host
3312            .open_channel_with_generated_test_machine_authority(test_session_id())
3313            .await
3314            .unwrap();
3315        host.attach_adapter(&ch, Arc::new(FailOnceCloseAdapter::default()))
3316            .await
3317            .unwrap();
3318        let observation = host.reserve_channel_close_observation(&ch).await.unwrap();
3319
3320        assert!(matches!(
3321            host.prepare_channel_physical_close(&observation).await,
3322            Err(LiveAdapterHostError::AdapterError(_))
3323        ));
3324        assert!(
3325            host.active_channels().await.contains(&ch),
3326            "failed physical close must retain host discovery for retry"
3327        );
3328        let premature_authority = host
3329            .close_commit_authority_from_generated_test_machine(&observation)
3330            .await
3331            .unwrap();
3332        assert!(matches!(
3333            host.commit_channel_close_observation(&observation, &premature_authority)
3334                .await,
3335            Err(LiveAdapterHostError::CloseNotAuthorized)
3336        ));
3337
3338        host.prepare_channel_physical_close(&observation)
3339            .await
3340            .expect("retry physically closes the exact retained adapter");
3341        let authority = host
3342            .close_commit_authority_from_generated_test_machine(&observation)
3343            .await
3344            .unwrap();
3345        host.commit_channel_close_observation(&observation, &authority)
3346            .await
3347            .unwrap();
3348        assert_eq!(
3349            host.channel_status(&ch).await.unwrap(),
3350            LiveAdapterStatus::Closed
3351        );
3352    }
3353
3354    #[tokio::test]
3355    async fn close_channel_observation_advances_per_channel() {
3356        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3357        let ch = host
3358            .open_channel_with_generated_test_machine_authority(test_session_id())
3359            .await
3360            .unwrap();
3361
3362        let first = host
3363            .close_channel_observed_with_generated_test_machine_authority(&ch)
3364            .await
3365            .unwrap();
3366        let second = host
3367            .close_channel_observed_with_generated_test_machine_authority(&ch)
3368            .await
3369            .unwrap();
3370
3371        assert_eq!(first.channel_id(), ch.as_str());
3372        assert_eq!(first.close_sequence(), 1);
3373        assert_eq!(second.close_sequence(), 2);
3374        assert_eq!(
3375            host.channel_status(&ch).await.unwrap(),
3376            LiveAdapterStatus::Closed
3377        );
3378    }
3379
3380    #[tokio::test]
3381    async fn close_channel_allows_rebinding_same_session() {
3382        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3383        let session_id = test_session_id();
3384        let ch = host
3385            .open_channel_with_generated_test_machine_authority(session_id.clone())
3386            .await
3387            .unwrap();
3388        host.close_channel_with_generated_test_machine_authority(&ch)
3389            .await
3390            .unwrap();
3391        let ch2 = host
3392            .open_channel_with_generated_test_machine_authority(session_id)
3393            .await
3394            .unwrap();
3395        assert_ne!(ch, ch2);
3396    }
3397
3398    /// G1 (P1) regression: after close+rebind, the reap of the retired
3399    /// channel must NOT clear `by_session[S]` — that mapping now points at
3400    /// the rebound channel B. Previously the reap unconditionally executed
3401    /// `by_session.remove(S)`, stranding B and allowing a third open to
3402    /// create a duplicate active channel for the same session.
3403    #[tokio::test]
3404    async fn reap_of_retired_channel_preserves_rebound_session_mapping() {
3405        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3406        let session_id = test_session_id();
3407
3408        let ch_a = host
3409            .open_channel_with_generated_test_machine_authority(session_id.clone())
3410            .await
3411            .unwrap();
3412        host.close_channel_with_generated_test_machine_authority(&ch_a)
3413            .await
3414            .unwrap();
3415        let ch_b = host
3416            .open_channel_with_generated_test_machine_authority(session_id.clone())
3417            .await
3418            .unwrap();
3419        assert_ne!(ch_a, ch_b);
3420
3421        // Force-expire A's retire window so the reaper drops A on the next
3422        // sweep. B remains active (no `retire_at` set).
3423        {
3424            let mut inner = host.inner.lock().await;
3425            if let Some(channel) = inner.channels.get_mut(&ch_a) {
3426                channel.retire_at =
3427                    Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
3428            }
3429        }
3430
3431        // `active_channels` invokes the reaper. Post-reap: B is still
3432        // active and `by_session[S]` still names B.
3433        let active = host.active_channels().await;
3434        assert_eq!(active, vec![ch_b.clone()]);
3435        {
3436            let inner = host.inner.lock().await;
3437            assert_eq!(
3438                inner.by_session.get(&session_id),
3439                Some(&ch_b),
3440                "reap of retired A must not clear B's reverse mapping"
3441            );
3442            assert!(
3443                !inner.channels.contains_key(&ch_a),
3444                "retired channel A must be dropped"
3445            );
3446            assert!(
3447                inner.channels.contains_key(&ch_b),
3448                "rebound channel B must remain"
3449            );
3450        }
3451
3452        // A subsequent open for the same session must be rejected (B is
3453        // still bound) — the pre-fix bug allowed it to succeed and create
3454        // a duplicate active channel.
3455        let err = host
3456            .open_channel_with_generated_test_machine_authority(session_id.clone())
3457            .await
3458            .unwrap_err();
3459        assert!(
3460            matches!(err, LiveAdapterHostError::SessionAlreadyBound(id) if id == session_id),
3461            "after reap, third open for session must still see B as bound"
3462        );
3463        assert_eq!(host.active_channels().await.len(), 1);
3464    }
3465
3466    /// G7 (P2) regression: a channel inside its post-close TTL retention
3467    /// window must not appear in `active_channels()`. Closed-but-retained
3468    /// entries stay in the underlying map (so the reaper and post-close
3469    /// status reads keep working) but the public `active_channels()`
3470    /// accessor must not advertise them — callers like
3471    /// `propagate_config_to_live_channels` would otherwise fan config
3472    /// swaps out at retired channels. Post-reap, the channel is gone
3473    /// from the map entirely, so it must still not appear.
3474    #[tokio::test]
3475    async fn active_channels_excludes_retained_closed_channels() {
3476        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3477        let s1 = test_session_id();
3478        let s2 = test_session_id();
3479
3480        let live = host
3481            .open_channel_with_generated_test_machine_authority(s1)
3482            .await
3483            .unwrap();
3484        let closing = host
3485            .open_channel_with_generated_test_machine_authority(s2)
3486            .await
3487            .unwrap();
3488
3489        // Both freshly opened — both should be active.
3490        let active_pre = host.active_channels().await;
3491        assert!(active_pre.contains(&live));
3492        assert!(active_pre.contains(&closing));
3493        assert_eq!(active_pre.len(), 2);
3494
3495        // Close one. It enters the TTL retention window
3496        // (`retire_at = Some(now + CLOSED_CHANNEL_TTL)`); the reaper
3497        // does NOT drop it yet.
3498        host.close_channel_with_generated_test_machine_authority(&closing)
3499            .await
3500            .unwrap();
3501
3502        // G7: the retained-closed channel must NOT appear in
3503        // `active_channels()`, even though it's still in the underlying
3504        // map. The live channel must still appear.
3505        let active_during_ttl = host.active_channels().await;
3506        assert_eq!(
3507            active_during_ttl,
3508            vec![live.clone()],
3509            "retained-closed channel must not appear in active_channels()"
3510        );
3511        // Post-close status reads still succeed (channel is in the map).
3512        assert_eq!(
3513            host.channel_status(&closing).await.unwrap(),
3514            LiveAdapterStatus::Closed,
3515        );
3516
3517        // Force-expire the TTL and drive the reap; the closed channel
3518        // is dropped from the map. Still must not appear in
3519        // `active_channels()`.
3520        {
3521            let mut inner = host.inner.lock().await;
3522            if let Some(channel) = inner.channels.get_mut(&closing) {
3523                channel.retire_at =
3524                    Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
3525            }
3526        }
3527        let active_post_reap = host.active_channels().await;
3528        assert_eq!(active_post_reap, vec![live.clone()]);
3529        {
3530            let inner = host.inner.lock().await;
3531            assert!(
3532                !inner.channels.contains_key(&closing),
3533                "post-reap, retired channel must be dropped from the map"
3534            );
3535        }
3536    }
3537
3538    #[tokio::test]
3539    async fn channel_session_returns_bound_session() {
3540        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3541        let session_id = test_session_id();
3542        let ch = host
3543            .open_channel_with_generated_test_machine_authority(session_id.clone())
3544            .await
3545            .unwrap();
3546        assert_eq!(host.channel_session(&ch).await.unwrap(), session_id);
3547    }
3548
3549    /// CC1: `signal_terminal_error` must enqueue a synthetic `Error`
3550    /// observation and close the underlying adapter. The pending obs must
3551    /// be readable through `next_observation_raw` even after the channel
3552    /// has transitioned to `Closed` (the slot lives on `ChannelState`, not
3553    /// on the adapter), so transports can observe it after generated close
3554    /// authority accepts the channel close.
3555    #[tokio::test]
3556    async fn signal_terminal_error_enqueues_synthetic_error_obs_and_closes_channel() {
3557        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3558        let ch = host
3559            .open_channel_with_generated_test_machine_authority(test_session_id())
3560            .await
3561            .unwrap();
3562        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
3563            .await
3564            .unwrap();
3565
3566        let code = LiveAdapterErrorCode::ConfigRejected {
3567            reason: meerkat_core::live_adapter::LiveConfigRejectionReason::RefreshModelSwap {
3568                from_model: "gpt-realtime".to_string(),
3569                to_model: "gpt-realtime-1.5".to_string(),
3570            },
3571        };
3572        host.signal_terminal_error(&ch, code).await.unwrap();
3573
3574        // Channel transitions to Closed as part of the seam.
3575        let status = host.channel_status(&ch).await.unwrap();
3576        assert_eq!(status, LiveAdapterStatus::Closed);
3577
3578        // The pending synthetic obs is still readable post-close — the
3579        // pending-slot check in `next_observation_raw` happens BEFORE the
3580        // `adapter_for` retire-at gate.
3581        let obs = host
3582            .next_observation_raw(&ch)
3583            .await
3584            .expect("next_observation_raw should return synthetic obs even post-close")
3585            .expect("synthetic obs must be Some");
3586        match obs {
3587            LiveAdapterObservation::Error { code, message } => match code {
3588                LiveAdapterErrorCode::ConfigRejected { reason } => {
3589                    assert!(matches!(
3590                        reason,
3591                        meerkat_core::live_adapter::LiveConfigRejectionReason::RefreshModelSwap {
3592                            ref to_model,
3593                            ..
3594                        } if to_model == "gpt-realtime-1.5"
3595                    ));
3596                    // R5-2: synthetic Error.message mirrors the reason's
3597                    // Display projection — the human-readable swap text
3598                    // still appears in the rendered message.
3599                    assert!(message.contains("close + reopen"));
3600                }
3601                other => panic!("expected ConfigRejected, got {other:?}"),
3602            },
3603            other => panic!("expected Error observation, got {other:?}"),
3604        }
3605    }
3606
3607    /// CC1: applying the synthetic `Error` observation through
3608    /// `apply_observation` must produce `ObservationOutcome::Terminal` with
3609    /// the same `ConfigRejected` code after generated close authority has
3610    /// accepted the channel close.
3611    #[tokio::test]
3612    async fn synthetic_terminal_error_routes_through_apply_observation_to_terminal_outcome() {
3613        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3614        let ch = host
3615            .open_channel_with_generated_test_machine_authority(test_session_id())
3616            .await
3617            .unwrap();
3618        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
3619            .await
3620            .unwrap();
3621
3622        let code = LiveAdapterErrorCode::ConfigRejected {
3623            reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ChannelIdentitySwap {
3624                from_model: "a".to_string(),
3625                from_provider: meerkat_core::Provider::OpenAI,
3626                to_model: "b".to_string(),
3627                to_provider: meerkat_core::Provider::OpenAI,
3628                auth_binding_changed: false,
3629            },
3630        };
3631        host.signal_terminal_error(&ch, code).await.unwrap();
3632
3633        let obs = host.next_observation_raw(&ch).await.unwrap().unwrap();
3634        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
3635        match outcome {
3636            ObservationOutcome::Terminal { code } => match code {
3637                LiveAdapterErrorCode::ConfigRejected { reason } => {
3638                    assert!(matches!(
3639                        reason,
3640                        meerkat_core::live_adapter::LiveConfigRejectionReason::ChannelIdentitySwap {
3641                            ref from_model, ref to_model, ..
3642                        } if from_model == "a" && to_model == "b"
3643                    ));
3644                }
3645                other => panic!("expected ConfigRejected, got {other:?}"),
3646            },
3647            other => panic!("expected Terminal outcome, got {other:?}"),
3648        }
3649    }
3650
3651    /// R5-3: `signal_terminal_error` must deliver the synthetic
3652    /// `LiveAdapterObservation::Error` to the consumer *before* the
3653    /// channel-closed end-of-stream signal. Verified end-to-end via
3654    /// the `pending_synthetic_obs` fallback: the consumer reads the
3655    /// typed Error, then a subsequent read returns `None` (the
3656    /// channel-closed signal). The legacy race — where the in-flight
3657    /// `next_observation()` returned `None` first and the typed
3658    /// signal was lost — must not recur.
3659    #[tokio::test]
3660    async fn signal_terminal_error_delivers_synthetic_error_before_close_signal() {
3661        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3662        let ch = host
3663            .open_channel_with_generated_test_machine_authority(test_session_id())
3664            .await
3665            .unwrap();
3666        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
3667            .await
3668            .unwrap();
3669
3670        let code = LiveAdapterErrorCode::ConfigRejected {
3671            reason: meerkat_core::live_adapter::LiveConfigRejectionReason::Other {
3672                detail: "model_swap_test".to_string(),
3673            },
3674        };
3675        host.signal_terminal_error(&ch, code).await.unwrap();
3676
3677        // First read: synthetic Error (typed, with reason).
3678        let first = host
3679            .next_observation_raw(&ch)
3680            .await
3681            .expect("first read should succeed")
3682            .expect("synthetic Error must surface before end-of-stream");
3683        match first {
3684            LiveAdapterObservation::Error { code, message } => match code {
3685                LiveAdapterErrorCode::ConfigRejected { reason } => {
3686                    // R5-2: `Other.detail` is the diagnostic catch-all and
3687                    // its `Display` projection equals the detail text, so
3688                    // the synthetic `Error.message` matches verbatim.
3689                    assert!(matches!(
3690                        &reason,
3691                        meerkat_core::live_adapter::LiveConfigRejectionReason::Other { detail }
3692                            if detail == "model_swap_test"
3693                    ));
3694                    assert_eq!(message, "model_swap_test");
3695                }
3696                other => unreachable!("expected ConfigRejected, got {other:?}"),
3697            },
3698            other => unreachable!("expected Error obs first, got {other:?}"),
3699        }
3700    }
3701
3702    /// CC1: `signal_terminal_error` on an unknown channel must surface
3703    /// `ChannelNotFound`, mirroring the rest of the host's per-channel
3704    /// surface. Without this, the runtime swap path could silently drop
3705    /// the typed signal on a channel that was already reaped.
3706    #[tokio::test]
3707    async fn signal_terminal_error_on_missing_channel_returns_channel_not_found() {
3708        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3709        let bogus = LiveChannelId::random_uuid();
3710        let result = host
3711            .signal_terminal_error(
3712                &bogus,
3713                LiveAdapterErrorCode::ConfigRejected {
3714                    reason: meerkat_core::live_adapter::LiveConfigRejectionReason::Other {
3715                        detail: "no channel".into(),
3716                    },
3717                },
3718            )
3719            .await;
3720        assert!(
3721            matches!(&result, Err(LiveAdapterHostError::ChannelNotFound(id)) if id == &bogus),
3722            "expected ChannelNotFound for unknown channel, got {result:?}"
3723        );
3724    }
3725
3726    // -- Observation classification --
3727
3728    #[test]
3729    fn ready_observation_routes_to_status_update() {
3730        let routing = LiveAdapterHost::classify_observation(&LiveAdapterObservation::Ready);
3731        assert_eq!(
3732            routing,
3733            ObservationRouting::UpdateStatus(LiveAdapterStatus::Ready)
3734        );
3735    }
3736
3737    #[test]
3738    fn tool_call_observation_routes_to_dispatch() {
3739        let obs = LiveAdapterObservation::ToolCallRequested {
3740            provider_call_id: ToolCallId::new("call_1"),
3741            tool_name: ToolName::new("calculator"),
3742            arguments: serde_json::json!({"x": 1}),
3743        };
3744        let routing = LiveAdapterHost::classify_observation(&obs);
3745        assert_eq!(
3746            routing,
3747            ObservationRouting::DispatchToolCall {
3748                provider_call_id: "call_1".into(),
3749                tool_name: "calculator".into(),
3750            }
3751        );
3752    }
3753
3754    #[test]
3755    fn barge_in_observation_routes_to_interrupt() {
3756        let routing =
3757            LiveAdapterHost::classify_observation(&LiveAdapterObservation::TurnInterrupted {
3758                response_id: None,
3759            });
3760        assert_eq!(routing, ObservationRouting::SignalInterrupt);
3761        let routing_with_id =
3762            LiveAdapterHost::classify_observation(&LiveAdapterObservation::TurnInterrupted {
3763                response_id: Some("resp_42".into()),
3764            });
3765        assert_eq!(routing_with_id, ObservationRouting::SignalInterrupt);
3766    }
3767
3768    #[test]
3769    fn user_transcript_routes_to_append() {
3770        let obs = LiveAdapterObservation::UserTranscriptFinal {
3771            provider_item_id: Some("item_1".into()),
3772            previous_item_id: None,
3773            content_index: None,
3774            text: "hello".into(),
3775        };
3776        assert_eq!(
3777            LiveAdapterHost::classify_observation(&obs),
3778            ObservationRouting::AppendTranscript
3779        );
3780    }
3781
3782    #[test]
3783    fn assistant_text_delta_routes_to_append() {
3784        let obs = LiveAdapterObservation::AssistantTextDelta {
3785            provider_item_id: Some("item_2".into()),
3786            previous_item_id: None,
3787            content_index: None,
3788            response_id: None,
3789            delta_id: None,
3790            delta: "world".into(),
3791        };
3792        assert_eq!(
3793            LiveAdapterHost::classify_observation(&obs),
3794            ObservationRouting::AppendTranscript
3795        );
3796    }
3797
3798    #[test]
3799    fn turn_completed_routes_to_append() {
3800        let obs = LiveAdapterObservation::TurnCompleted {
3801            response_id: None,
3802            stop_reason: StopReason::EndTurn,
3803            usage: Usage {
3804                input_tokens: 10,
3805                output_tokens: 5,
3806                cache_creation_tokens: None,
3807                cache_read_tokens: None,
3808            },
3809        };
3810        assert_eq!(
3811            LiveAdapterHost::classify_observation(&obs),
3812            ObservationRouting::AppendTranscript
3813        );
3814    }
3815
3816    #[test]
3817    fn error_observation_routes_to_terminal() {
3818        let obs = LiveAdapterObservation::Error {
3819            code: LiveAdapterErrorCode::ConnectionLost,
3820            message: "ws closed".into(),
3821        };
3822        assert_eq!(
3823            LiveAdapterHost::classify_observation(&obs),
3824            ObservationRouting::TerminalError
3825        );
3826    }
3827
3828    #[test]
3829    fn audio_chunk_routes_to_noop() {
3830        let obs = LiveAdapterObservation::AssistantAudioChunk {
3831            data: vec![0; 100],
3832            sample_rate_hz: 24000,
3833            channels: 1,
3834            response_id: None,
3835            item_id: None,
3836            content_index: None,
3837        };
3838        assert_eq!(
3839            LiveAdapterHost::classify_observation(&obs),
3840            ObservationRouting::Noop
3841        );
3842    }
3843
3844    #[test]
3845    fn user_content_committed_receipt_routes_to_noop() {
3846        let obs = LiveAdapterObservation::UserContentCommitted {
3847            idempotency_key: "image-request-1".into(),
3848            item_id: "item_image".into(),
3849            previous_item_id: None,
3850            content_index: 0,
3851            media_type: "image/png".into(),
3852        };
3853        assert_eq!(
3854            LiveAdapterHost::classify_observation(&obs),
3855            ObservationRouting::Noop
3856        );
3857    }
3858
3859    #[test]
3860    fn status_changed_routes_to_status_update() {
3861        let obs = LiveAdapterObservation::StatusChanged {
3862            status: LiveAdapterStatus::Degraded {
3863                reason: LiveDegradationReason::ProviderThrottled,
3864            },
3865        };
3866        assert_eq!(
3867            LiveAdapterHost::classify_observation(&obs),
3868            ObservationRouting::UpdateStatus(LiveAdapterStatus::Degraded {
3869                reason: LiveDegradationReason::ProviderThrottled,
3870            })
3871        );
3872    }
3873
3874    // -- Status tracking --
3875
3876    #[tokio::test]
3877    async fn commit_status_with_generated_authority_changes_channel_status() {
3878        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3879        let ch = host
3880            .open_channel_with_generated_test_machine_authority(test_session_id())
3881            .await
3882            .unwrap();
3883        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
3884            .await
3885            .unwrap();
3886        assert_eq!(
3887            host.channel_status(&ch).await.unwrap(),
3888            LiveAdapterStatus::Ready
3889        );
3890    }
3891
3892    #[tokio::test]
3893    async fn terminal_status_update_requires_generated_close_authority() {
3894        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3895        let ch = host
3896            .open_channel_with_generated_test_machine_authority(test_session_id())
3897            .await
3898            .unwrap();
3899        let err = host
3900            .commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Closed)
3901            .await
3902            .expect_err("terminal status update must not bypass generated close authority");
3903        assert!(matches!(err, LiveAdapterHostError::StatusNotAuthorized));
3904
3905        let obs = LiveAdapterObservation::StatusChanged {
3906            status: LiveAdapterStatus::Closed,
3907        };
3908        let err = host
3909            .apply_observation(&ch, &obs)
3910            .await
3911            .expect_err("closed observation must not bypass generated close authority");
3912        assert!(matches!(err, LiveAdapterHostError::CloseNotAuthorized));
3913
3914        host.close_channel_with_generated_test_machine_authority(&ch)
3915            .await
3916            .unwrap();
3917        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
3918        assert_eq!(
3919            outcome,
3920            ObservationOutcome::StatusUpdated(LiveAdapterStatus::Closed)
3921        );
3922    }
3923
3924    // -- Snapshot versioning --
3925
3926    #[tokio::test]
3927    async fn snapshot_version_increments_monotonically() {
3928        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3929        let ch = host
3930            .open_channel_with_generated_test_machine_authority(test_session_id())
3931            .await
3932            .unwrap();
3933        let v1 = host.next_snapshot_version(&ch).await.unwrap();
3934        let v2 = host.next_snapshot_version(&ch).await.unwrap();
3935        assert_eq!(v1, 1);
3936        assert_eq!(v2, 2);
3937    }
3938
3939    // -- Active channels --
3940
3941    #[tokio::test]
3942    async fn active_channels_lists_open_channels() {
3943        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3944        let ch1 = host
3945            .open_channel_with_generated_test_machine_authority(test_session_id())
3946            .await
3947            .unwrap();
3948        let ch2 = host
3949            .open_channel_with_generated_test_machine_authority(test_session_id())
3950            .await
3951            .unwrap();
3952        let active = host.active_channels().await;
3953        assert_eq!(active.len(), 2);
3954        assert!(active.contains(&ch1));
3955        assert!(active.contains(&ch2));
3956    }
3957
3958    // -- Adapter attachment --
3959
3960    #[tokio::test]
3961    async fn send_input_without_adapter_returns_error() {
3962        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3963        let ch = host
3964            .open_channel_with_generated_test_machine_authority(test_session_id())
3965            .await
3966            .unwrap();
3967        let err = host
3968            .send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
3969            .await
3970            .unwrap_err();
3971        // F31: with no adapter, status is still `Opening`; rejection is the
3972        // not-ready guard. (If status were `Ready`, we'd hit `NoAdapter`.)
3973        assert!(matches!(err, LiveAdapterHostError::ChannelNotReady(_, _)));
3974    }
3975
3976    #[tokio::test]
3977    async fn send_command_rejects_refresh_without_typed_acceptance_path() {
3978        let session_id = test_session_id();
3979        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3980        let ch = host
3981            .open_channel_with_generated_test_machine_authority(session_id.clone())
3982            .await
3983            .unwrap();
3984        let snapshot = meerkat_core::live_adapter::LiveProjectionSnapshot {
3985            session_id,
3986            snapshot_version: 1,
3987            seed_messages: vec![],
3988            visible_tools: vec![],
3989            system_prompt: None,
3990            model_id: "model-a".into(),
3991            provider_id: meerkat_core::Provider::Other,
3992            audio_config: None,
3993            runtime_system_context: vec![],
3994            user_content_identities: vec![],
3995            user_content_tombstones: vec![],
3996            canonical_user_image_decoded_bytes: None,
3997            transcript_rewrite_generation: 0,
3998        };
3999
4000        let err = host
4001            .send_command(&ch, LiveAdapterCommand::Refresh { snapshot })
4002            .await
4003            .unwrap_err();
4004
4005        assert!(matches!(err, LiveAdapterHostError::UnsupportedCommand(_)));
4006    }
4007
4008    #[tokio::test]
4009    async fn attach_adapter_does_not_assert_ready() {
4010        // F32: attach leaves status Opening; only `Ready` observation
4011        // promotes the channel.
4012        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4013        let ch = host
4014            .open_channel_with_generated_test_machine_authority(test_session_id())
4015            .await
4016            .unwrap();
4017        assert_eq!(
4018            host.channel_status(&ch).await.unwrap(),
4019            LiveAdapterStatus::Opening
4020        );
4021        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4022            .await
4023            .unwrap();
4024        assert_eq!(
4025            host.channel_status(&ch).await.unwrap(),
4026            LiveAdapterStatus::Opening,
4027            "attach_adapter must NOT mark channel Ready (F32)"
4028        );
4029    }
4030
4031    // -- F31: send_input gate --
4032
4033    #[tokio::test]
4034    async fn send_input_rejected_when_not_ready() {
4035        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4036        let ch = host
4037            .open_channel_with_generated_test_machine_authority(test_session_id())
4038            .await
4039            .unwrap();
4040        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4041            .await
4042            .unwrap();
4043        // Status is still `Opening` — `accepts_commands()` is false.
4044        let err = host
4045            .send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
4046            .await
4047            .unwrap_err();
4048        match err {
4049            LiveAdapterHostError::ChannelNotReady(_, status) => {
4050                assert_eq!(status, LiveAdapterStatus::Opening);
4051            }
4052            other => panic!("expected ChannelNotReady, got {other:?}"),
4053        }
4054    }
4055
4056    #[tokio::test]
4057    async fn send_input_accepts_when_ready() {
4058        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4059        let ch = host
4060            .open_channel_with_generated_test_machine_authority(test_session_id())
4061            .await
4062            .unwrap();
4063        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4064            .await
4065            .unwrap();
4066        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4067            .await
4068            .unwrap();
4069        host.send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
4070            .await
4071            .unwrap();
4072    }
4073
4074    // -- E29: typed adapter error --
4075
4076    #[tokio::test]
4077    async fn adapter_pump_error_routes_close_through_generated_authority() {
4078        // Adapter pump failures surface a synthetic terminal observation.
4079        // The host does not retire the channel until the transport routes
4080        // that terminal observation through generated close authority.
4081        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4082        let ch = host
4083            .open_channel_with_generated_test_machine_authority(test_session_id())
4084            .await
4085            .unwrap();
4086        host.attach_adapter(&ch, Arc::new(ErroringAdapter))
4087            .await
4088            .unwrap();
4089        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4090            .await
4091            .unwrap();
4092        let obs = host
4093            .next_observation_raw(&ch)
4094            .await
4095            .unwrap()
4096            .expect("synthetic Error obs surfaces on adapter Err");
4097        match &obs {
4098            LiveAdapterObservation::Error { code, message } => {
4099                assert_eq!(*code, LiveAdapterErrorCode::ProviderError);
4100                assert!(
4101                    message.contains("adapter read failure"),
4102                    "synthetic message must explain origin; got `{message}`"
4103                );
4104            }
4105            other => unreachable!("expected synthetic Error, got {other:?}"),
4106        }
4107        let err = host
4108            .apply_observation(&ch, &obs)
4109            .await
4110            .expect_err("terminal observation must wait for generated close authority");
4111        assert!(matches!(err, LiveAdapterHostError::CloseNotAuthorized));
4112
4113        // Terminal observation routing waits for generated close authority.
4114        let status = host.channel_status(&ch).await.unwrap();
4115        assert_eq!(status, LiveAdapterStatus::Ready);
4116        {
4117            let inner = host.inner.lock().await;
4118            let channel = inner
4119                .channels
4120                .get(&ch)
4121                .expect("channel remains present before close authority");
4122            assert!(
4123                channel.retire_at.is_none(),
4124                "adapter Err must not set retire_at before generated close authority"
4125            );
4126            assert!(
4127                channel.adapter.is_some(),
4128                "adapter Err must not drop the adapter before generated close authority"
4129            );
4130        }
4131
4132        host.close_channel_with_generated_test_machine_authority(&ch)
4133            .await
4134            .unwrap();
4135        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4136        assert_eq!(
4137            outcome,
4138            ObservationOutcome::Terminal {
4139                code: LiveAdapterErrorCode::ProviderError
4140            }
4141        );
4142        let status = host.channel_status(&ch).await.unwrap();
4143        assert_eq!(status, LiveAdapterStatus::Closed);
4144        {
4145            let inner = host.inner.lock().await;
4146            let channel = inner
4147                .channels
4148                .get(&ch)
4149                .expect("channel preserved for live/status until TTL elapses");
4150            assert!(
4151                channel.retire_at.is_some(),
4152                "generated close authority must set retire_at"
4153            );
4154            assert!(
4155                channel.adapter.is_none(),
4156                "generated close authority must drop the adapter Arc"
4157            );
4158        }
4159    }
4160
4161    /// R5-9: a `LiveAdapterObservation::CommandRejected` flowing
4162    /// through `apply_observation` produces a typed
4163    /// `ObservationOutcome::CommandRejected` — NOT
4164    /// `ObservationOutcome::Terminal`. The channel state must remain
4165    /// untouched (status, retire_at, adapter all preserved) so a
4166    /// follow-up command can be sent on the same channel.
4167    #[tokio::test]
4168    async fn command_rejected_routes_non_terminally_and_preserves_channel() {
4169        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4170        let ch = host
4171            .open_channel_with_generated_test_machine_authority(test_session_id())
4172            .await
4173            .unwrap();
4174        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4175            .await
4176            .unwrap();
4177        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4178            .await
4179            .unwrap();
4180
4181        let obs = LiveAdapterObservation::CommandRejected {
4182            code: LiveAdapterErrorCode::ConfigRejected {
4183                reason:
4184                    meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputNotImplemented,
4185            },
4186            message: "image_input_not_implemented".into(),
4187        };
4188        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4189        match outcome {
4190            ObservationOutcome::CommandRejected { code, message } => {
4191                assert!(matches!(
4192                    code,
4193                    LiveAdapterErrorCode::ConfigRejected {
4194                        reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputNotImplemented,
4195                    }
4196                ));
4197                assert_eq!(message, "image_input_not_implemented");
4198            }
4199            other => {
4200                unreachable!("CommandRejected must produce CommandRejected outcome, got {other:?}")
4201            }
4202        }
4203
4204        // Channel remains live — status untouched, retire_at not set,
4205        // adapter still attached.
4206        let status = host.channel_status(&ch).await.unwrap();
4207        assert_eq!(status, LiveAdapterStatus::Ready);
4208        {
4209            let inner = host.inner.lock().await;
4210            let channel = inner.channels.get(&ch).expect("channel present");
4211            assert!(
4212                channel.retire_at.is_none(),
4213                "CommandRejected must not retire the channel"
4214            );
4215            assert!(
4216                channel.adapter.is_some(),
4217                "CommandRejected must not drop the adapter"
4218            );
4219        }
4220    }
4221
4222    /// R5-8: after the adapter pump errors, the channel is fully
4223    /// retired and `open_channel` for the same session id succeeds
4224    /// after the previous binding's retire reaper sweeps. Without
4225    /// `retire_at` being set on adapter Err, the legacy code path
4226    /// stranded the session — `open_channel` rejected the rebind with
4227    /// `SessionAlreadyBound` indefinitely.
4228    #[tokio::test]
4229    async fn adapter_err_releases_session_for_rebind() {
4230        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4231        let session_id = test_session_id();
4232        let ch1 = host
4233            .open_channel_with_generated_test_machine_authority(session_id.clone())
4234            .await
4235            .unwrap();
4236        host.attach_adapter(&ch1, Arc::new(ErroringAdapter))
4237            .await
4238            .unwrap();
4239        host.commit_status_with_generated_test_machine_authority(&ch1, LiveAdapterStatus::Ready)
4240            .await
4241            .unwrap();
4242
4243        // Trigger the adapter error (and the host's R5-8 cleanup).
4244        let _ = host.next_observation_raw(&ch1).await.unwrap();
4245
4246        // Force-expire the retire window so the reaper accepts the
4247        // rebind without sleeping for `CLOSED_CHANNEL_TTL` seconds.
4248        {
4249            let mut inner = host.inner.lock().await;
4250            if let Some(channel) = inner.channels.get_mut(&ch1) {
4251                channel.retire_at =
4252                    Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
4253            }
4254        }
4255
4256        let ch2 = host
4257            .open_channel_with_generated_test_machine_authority(session_id.clone())
4258            .await
4259            .expect("rebind for same session must succeed once previous channel is retired");
4260        assert_ne!(ch1, ch2);
4261    }
4262
4263    // -- A2/A3: transcript projection --
4264
4265    #[tokio::test]
4266    async fn user_transcript_observation_appends_to_sink() {
4267        let sink = Arc::new(RecordingProjectionSink::default());
4268        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4269        let session_id = test_session_id();
4270        let ch = host
4271            .open_channel_with_generated_test_machine_authority(session_id.clone())
4272            .await
4273            .unwrap();
4274        let obs = LiveAdapterObservation::UserTranscriptFinal {
4275            provider_item_id: Some("item_1".into()),
4276            previous_item_id: None,
4277            content_index: None,
4278            text: "hello world".into(),
4279        };
4280        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4281        assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4282        let user = sink.user_transcripts.lock().unwrap();
4283        assert_eq!(user.len(), 1);
4284        assert_eq!(user[0].0, session_id);
4285        assert_eq!(user[0].1, "hello world");
4286        assert_eq!(user[0].2.provider_item_id.as_deref(), Some("item_1"));
4287    }
4288
4289    #[tokio::test]
4290    async fn user_transcript_full_identity_propagates_end_to_end() {
4291        // A11 contract: every identity field carried by
4292        // `LiveAdapterObservation::UserTranscriptFinal` must reach the sink.
4293        let sink = Arc::new(RecordingProjectionSink::default());
4294        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4295        let session_id = test_session_id();
4296        let ch = host
4297            .open_channel_with_generated_test_machine_authority(session_id.clone())
4298            .await
4299            .unwrap();
4300        let obs = LiveAdapterObservation::UserTranscriptFinal {
4301            provider_item_id: Some("item_1".into()),
4302            previous_item_id: Some("item_0".into()),
4303            content_index: Some(2),
4304            text: "hello".into(),
4305        };
4306        host.apply_observation(&ch, &obs).await.unwrap();
4307        let user = sink.user_transcripts.lock().unwrap();
4308        let identity = &user[0].2;
4309        assert_eq!(identity.provider_item_id.as_deref(), Some("item_1"));
4310        assert_eq!(identity.previous_item_id.as_deref(), Some("item_0"));
4311        assert_eq!(identity.content_index, Some(2));
4312        // Response/delta IDs are assistant-only; ensure they do not leak
4313        // through on the user side.
4314        assert_eq!(identity.response_id, None);
4315        assert_eq!(identity.delta_id, None);
4316    }
4317
4318    #[tokio::test]
4319    async fn assistant_text_delta_observation_appends_to_sink() {
4320        let sink = Arc::new(RecordingProjectionSink::default());
4321        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4322        let session_id = test_session_id();
4323        let ch = host
4324            .open_channel_with_generated_test_machine_authority(session_id.clone())
4325            .await
4326            .unwrap();
4327        let obs = LiveAdapterObservation::AssistantTextDelta {
4328            provider_item_id: None,
4329            previous_item_id: None,
4330            content_index: None,
4331            response_id: None,
4332            delta_id: None,
4333            delta: "Hello".into(),
4334        };
4335        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4336        assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4337        // T6: AssistantTextDelta routes to the text lane.
4338        let deltas = sink.text_deltas.lock().unwrap();
4339        assert_eq!(deltas.len(), 1);
4340        assert_eq!(deltas[0].0, session_id);
4341        assert_eq!(deltas[0].1, "Hello");
4342        // The transcript lane stays empty for a display-text observation.
4343        assert!(sink.transcript_deltas.lock().unwrap().is_empty());
4344    }
4345
4346    #[tokio::test]
4347    async fn assistant_text_delta_full_identity_propagates_end_to_end() {
4348        // A11 contract: every identity field on `AssistantTextDelta` —
4349        // including `response_id` and `delta_id` — must reach the sink.
4350        let sink = Arc::new(RecordingProjectionSink::default());
4351        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4352        let ch = host
4353            .open_channel_with_generated_test_machine_authority(test_session_id())
4354            .await
4355            .unwrap();
4356        let obs = LiveAdapterObservation::AssistantTextDelta {
4357            provider_item_id: Some("item_42".into()),
4358            previous_item_id: Some("item_41".into()),
4359            content_index: Some(1),
4360            response_id: Some("resp_xyz".into()),
4361            delta_id: Some("d_7".into()),
4362            delta: "world".into(),
4363        };
4364        host.apply_observation(&ch, &obs).await.unwrap();
4365        let deltas = sink.text_deltas.lock().unwrap();
4366        let identity = &deltas[0].2;
4367        assert_eq!(identity.provider_item_id.as_deref(), Some("item_42"));
4368        assert_eq!(identity.previous_item_id.as_deref(), Some("item_41"));
4369        assert_eq!(identity.content_index, Some(1));
4370        assert_eq!(identity.response_id.as_deref(), Some("resp_xyz"));
4371        assert_eq!(identity.delta_id.as_deref(), Some("d_7"));
4372    }
4373
4374    #[tokio::test]
4375    async fn assistant_transcript_final_observation_calls_sink() {
4376        let sink = Arc::new(RecordingProjectionSink::default());
4377        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4378        let session_id = test_session_id();
4379        let ch = host
4380            .open_channel_with_generated_test_machine_authority(session_id.clone())
4381            .await
4382            .unwrap();
4383        let obs = LiveAdapterObservation::AssistantTranscriptFinal {
4384            provider_item_id: "resp_1".into(),
4385            previous_item_id: None,
4386            content_index: None,
4387            response_id: None,
4388            text: "All done.".into(),
4389            stop_reason: StopReason::EndTurn,
4390            usage: Usage::default(),
4391        };
4392        host.apply_observation(&ch, &obs).await.unwrap();
4393        // T6: AssistantTranscriptFinal routes to the transcript lane.
4394        let finals = sink.transcript_finals.lock().unwrap();
4395        assert_eq!(finals.len(), 1);
4396        assert_eq!(finals[0].0, session_id);
4397        assert_eq!(finals[0].1, "All done.");
4398        assert!(sink.text_finals.lock().unwrap().is_empty());
4399    }
4400
4401    #[tokio::test]
4402    async fn assistant_transcript_final_full_identity_propagates_end_to_end() {
4403        // A11: AssistantTranscriptFinal carries provider_item_id (required),
4404        // previous_item_id, content_index, response_id. delta_id is not
4405        // applicable to a final.
4406        let sink = Arc::new(RecordingProjectionSink::default());
4407        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4408        let ch = host
4409            .open_channel_with_generated_test_machine_authority(test_session_id())
4410            .await
4411            .unwrap();
4412        let obs = LiveAdapterObservation::AssistantTranscriptFinal {
4413            provider_item_id: "item_final".into(),
4414            previous_item_id: Some("item_prev".into()),
4415            content_index: Some(0),
4416            response_id: Some("resp_final".into()),
4417            text: "done".into(),
4418            stop_reason: StopReason::EndTurn,
4419            usage: Usage::default(),
4420        };
4421        host.apply_observation(&ch, &obs).await.unwrap();
4422        let finals = sink.transcript_finals.lock().unwrap();
4423        let identity = &finals[0].2;
4424        assert_eq!(identity.provider_item_id.as_deref(), Some("item_final"));
4425        assert_eq!(identity.previous_item_id.as_deref(), Some("item_prev"));
4426        assert_eq!(identity.content_index, Some(0));
4427        assert_eq!(identity.response_id.as_deref(), Some("resp_final"));
4428        assert_eq!(identity.delta_id, None);
4429    }
4430
4431    #[tokio::test]
4432    async fn turn_completed_observation_signals_sink() {
4433        let sink = Arc::new(RecordingProjectionSink::default());
4434        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4435        let ch = host
4436            .open_channel_with_generated_test_machine_authority(test_session_id())
4437            .await
4438            .unwrap();
4439        let obs = LiveAdapterObservation::TurnCompleted {
4440            response_id: None,
4441            stop_reason: StopReason::EndTurn,
4442            usage: Usage::default(),
4443        };
4444        host.apply_observation(&ch, &obs).await.unwrap();
4445        let turns = sink.turn_completed.lock().unwrap();
4446        assert_eq!(turns.len(), 1);
4447    }
4448
4449    // -- T6: assistant_transcript_delta routes to transcript lane only --
4450
4451    #[tokio::test]
4452    async fn assistant_transcript_delta_routes_to_transcript_lane() {
4453        // T6: AssistantTranscriptDelta must reach the transcript-lane sink
4454        // method, NOT the text-lane method. The host classification +
4455        // apply_observation pair owns the dispatch; this test pins the
4456        // routing so a future refactor cannot collapse the lanes back.
4457        let sink = Arc::new(RecordingProjectionSink::default());
4458        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4459        let session_id = test_session_id();
4460        let ch = host
4461            .open_channel_with_generated_test_machine_authority(session_id.clone())
4462            .await
4463            .unwrap();
4464        let obs = LiveAdapterObservation::AssistantTranscriptDelta {
4465            provider_item_id: Some("item_t".into()),
4466            previous_item_id: None,
4467            content_index: Some(0),
4468            response_id: Some("resp_t".into()),
4469            delta_id: Some("d_t".into()),
4470            delta: "spoken word".into(),
4471        };
4472        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4473        assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4474        let transcript_deltas = sink.transcript_deltas.lock().unwrap();
4475        assert_eq!(transcript_deltas.len(), 1);
4476        assert_eq!(transcript_deltas[0].0, session_id);
4477        assert_eq!(transcript_deltas[0].1, "spoken word");
4478        // The text lane MUST stay empty for a transcript observation.
4479        assert!(
4480            sink.text_deltas.lock().unwrap().is_empty(),
4481            "AssistantTranscriptDelta must not reach the text-lane sink (T6)"
4482        );
4483    }
4484
4485    // -- P1#2: structured realtime transcript pass-through --
4486    //
4487    // Regression: provider adapters emit
4488    // `LiveAdapterObservation::RealtimeTranscript { event }` for events the
4489    // host previously fell through to `Noop` on (`ItemObserved`,
4490    // `ItemSkipped`, `AssistantTurnCompleted`, `AssistantTurnInterrupted`).
4491    // The host must route those through the typed sink seam so the session
4492    // runtime's idempotent ordering / staging machinery owns materialization
4493    // — the same path that already handles streaming assistant deltas.
4494
4495    #[tokio::test]
4496    async fn realtime_transcript_observation_routes_to_append_realtime_transcript() {
4497        use meerkat_core::RealtimeTranscriptRole;
4498        let sink = Arc::new(RecordingProjectionSink::default());
4499        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4500        let session_id = test_session_id();
4501        let ch = host
4502            .open_channel_with_generated_test_machine_authority(session_id.clone())
4503            .await
4504            .unwrap();
4505
4506        let event = RealtimeTranscriptEvent::ItemObserved {
4507            item_id: "item_realtime_1".into(),
4508            previous_item_id: Some("item_realtime_0".into()),
4509            role: RealtimeTranscriptRole::Assistant,
4510            response_id: Some("resp_realtime_1".into()),
4511        };
4512        let obs = LiveAdapterObservation::RealtimeTranscript {
4513            event: event.clone(),
4514        };
4515
4516        // Routing first: must NOT be Noop — it must be the structured pass-
4517        // through variant with the event preserved.
4518        let routing = LiveAdapterHost::classify_observation(&obs);
4519        match routing {
4520            ObservationRouting::AppendRealtimeTranscript => {}
4521            other => panic!("expected AppendRealtimeTranscript, got {other:?}"),
4522        }
4523
4524        // End-to-end: apply_observation routes through the sink exactly once.
4525        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4526        assert!(
4527            matches!(outcome, ObservationOutcome::TranscriptAppended),
4528            "expected TranscriptAppended, got {outcome:?}"
4529        );
4530
4531        let recorded = sink.realtime_events.lock().unwrap();
4532        assert_eq!(recorded.len(), 1, "sink must see exactly one append");
4533        assert_eq!(recorded[0].0, session_id);
4534        assert_eq!(recorded[0].1, event);
4535
4536        // No legacy fallthrough: `Noop` would mean nothing else in the sink
4537        // moved either. Pin that by checking adjacent recorders are empty.
4538        assert!(sink.text_deltas.lock().unwrap().is_empty());
4539        assert!(sink.transcript_deltas.lock().unwrap().is_empty());
4540        assert!(sink.text_finals.lock().unwrap().is_empty());
4541        assert!(sink.transcript_finals.lock().unwrap().is_empty());
4542        assert!(sink.user_transcripts.lock().unwrap().is_empty());
4543        assert!(sink.turn_completed.lock().unwrap().is_empty());
4544        assert!(sink.interrupts.lock().unwrap().is_empty());
4545    }
4546
4547    // R6-6 (P3 dogma): `LiveProjectionSink::append_realtime_transcript` is a
4548    // *required* trait method — there is no default body. This test pins the
4549    // path through `NoOpProjectionSink` (the explicit-opt-out sink) so that
4550    // a future refactor cannot reintroduce a silent default that drops
4551    // realtime events on the floor for "mandatory" sinks that forgot to
4552    // override the method.
4553    #[tokio::test]
4554    async fn noop_projection_sink_explicitly_accepts_realtime_transcript() {
4555        use meerkat_core::RealtimeTranscriptRole;
4556        let sink: Arc<dyn LiveProjectionSink> = Arc::new(NoOpProjectionSink);
4557        let host = LiveAdapterHost::new(Arc::clone(&sink));
4558        let session_id = test_session_id();
4559        let ch = host
4560            .open_channel_with_generated_test_machine_authority(session_id.clone())
4561            .await
4562            .unwrap();
4563
4564        let event = RealtimeTranscriptEvent::ItemObserved {
4565            item_id: "item_noop".into(),
4566            previous_item_id: None,
4567            role: RealtimeTranscriptRole::Assistant,
4568            response_id: Some("resp_noop".into()),
4569        };
4570        let obs = LiveAdapterObservation::RealtimeTranscript {
4571            event: event.clone(),
4572        };
4573
4574        // The host-side route must treat NoOpProjectionSink as a real owner
4575        // (TranscriptAppended), not a silent fallthrough — that is exactly
4576        // what the explicit `Ok(())` body on the impl guarantees once the
4577        // trait default is removed.
4578        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4579        assert!(
4580            matches!(outcome, ObservationOutcome::TranscriptAppended),
4581            "NoOpProjectionSink must accept RealtimeTranscript explicitly, got {outcome:?}"
4582        );
4583
4584        // And the direct trait call also has to compile + succeed — proving
4585        // the impl exists at the impl site, not via a trait default.
4586        sink.append_realtime_transcript(&session_id, &event)
4587            .await
4588            .expect("NoOpProjectionSink::append_realtime_transcript must be explicit Ok");
4589    }
4590
4591    #[tokio::test]
4592    async fn realtime_transcript_assistant_turn_completed_routes_through_sink() {
4593        // Pin that AssistantTurnCompleted — historically lost by the fall-
4594        // through to `Noop` — also reaches the sink. (Different inner variant
4595        // shape: stop_reason + usage, not item identity.)
4596        let sink = Arc::new(RecordingProjectionSink::default());
4597        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4598        let ch = host
4599            .open_channel_with_generated_test_machine_authority(test_session_id())
4600            .await
4601            .unwrap();
4602        let event = RealtimeTranscriptEvent::AssistantTurnCompleted {
4603            response_id: "resp_complete".into(),
4604            stop_reason: StopReason::EndTurn,
4605            usage: Usage::default(),
4606        };
4607        let obs = LiveAdapterObservation::RealtimeTranscript {
4608            event: event.clone(),
4609        };
4610        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4611        assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4612        let recorded = sink.realtime_events.lock().unwrap();
4613        assert_eq!(recorded.len(), 1);
4614        assert_eq!(recorded[0].1, event);
4615    }
4616
4617    // -- A4/A5: tool call dispatch + submit --
4618
4619    #[tokio::test]
4620    async fn tool_call_observation_dispatches_through_tool_authority() {
4621        let sink = Arc::new(RecordingProjectionSink::default());
4622        let dispatcher = Arc::new(RecordingDispatcher::default());
4623        let adapter = Arc::new(RecordingAdapter::default());
4624        let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4625            .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _);
4626        let ch = host
4627            .open_channel_with_generated_test_machine_authority(test_session_id())
4628            .await
4629            .unwrap();
4630        host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4631            .await
4632            .unwrap();
4633        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4634            .await
4635            .unwrap();
4636        let obs = LiveAdapterObservation::ToolCallRequested {
4637            provider_call_id: ToolCallId::new("call_42"),
4638            tool_name: ToolName::new("calculator"),
4639            arguments: serde_json::json!({"a": 2, "b": 3}),
4640        };
4641        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4642        match outcome {
4643            ObservationOutcome::ToolCallDispatched {
4644                provider_call_id,
4645                tool_name,
4646            } => {
4647                assert_eq!(provider_call_id, "call_42");
4648                assert_eq!(tool_name, "calculator");
4649            }
4650            other => panic!("expected ToolCallDispatched, got {other:?}"),
4651        }
4652        // Dispatcher saw the call.
4653        let calls = dispatcher.calls.lock().unwrap();
4654        assert_eq!(calls.len(), 1);
4655        assert_eq!(calls[0].0, "call_42");
4656        assert_eq!(calls[0].1, "calculator");
4657        // Adapter saw the SubmitToolResult command.
4658        let submitted = adapter.submitted_results.lock().unwrap();
4659        assert_eq!(submitted.len(), 1);
4660        assert_eq!(submitted[0].call_id.0, "call_42");
4661    }
4662
4663    #[tokio::test]
4664    async fn tool_call_skipped_when_no_dispatcher_wired() {
4665        let sink = Arc::new(RecordingProjectionSink::default());
4666        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4667        let ch = host
4668            .open_channel_with_generated_test_machine_authority(test_session_id())
4669            .await
4670            .unwrap();
4671        let obs = LiveAdapterObservation::ToolCallRequested {
4672            provider_call_id: ToolCallId::new("call_99"),
4673            tool_name: ToolName::new("calculator"),
4674            arguments: serde_json::json!({}),
4675        };
4676        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4677        match outcome {
4678            ObservationOutcome::ToolCallSkipped {
4679                reason: ToolDispatchSkipReason::NoDispatcher,
4680                ..
4681            } => {}
4682            other => panic!("expected ToolCallSkipped/NoDispatcher, got {other:?}"),
4683        }
4684    }
4685
4686    // -- P2#2: missing-dispatcher does not deadlock the live session --
4687    //
4688    // Regression: when no tool dispatcher is wired and a `ToolCallRequested`
4689    // observation arrives, the host previously returned `ToolCallSkipped {
4690    // NoDispatcher }` and silently dropped the call on the floor. The
4691    // provider then waited forever for a tool result that would never come,
4692    // deadlocking the live session until the provider's own timeout (or
4693    // never). The fix is to ALSO send a typed `SubmitToolError` back to the
4694    // adapter so the provider can complete the response with an error and
4695    // unstick its turn — while still surfacing the typed
4696    // `ToolCallSkipped/NoDispatcher` outcome for the host audit trail.
4697    #[tokio::test]
4698    async fn tool_call_no_dispatcher_submits_error_to_adapter() {
4699        let sink = Arc::new(RecordingProjectionSink::default());
4700        let adapter = Arc::new(RecordingAdapter::default());
4701        // No dispatcher installed.
4702        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4703        let ch = host
4704            .open_channel_with_generated_test_machine_authority(test_session_id())
4705            .await
4706            .unwrap();
4707        host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4708            .await
4709            .unwrap();
4710        // Status must accept commands so the adapter command path is exercised
4711        // (otherwise `send_command` rejects with ChannelNotReady — but the
4712        // helper used for SubmitToolError uses `require_ready=false` since the
4713        // command is allowed when not Ready; verifying via the generated status
4714        // commit helper anyway to mirror the production attach sequence).
4715        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4716            .await
4717            .unwrap();
4718
4719        let obs = LiveAdapterObservation::ToolCallRequested {
4720            provider_call_id: ToolCallId::new("call_unwired"),
4721            tool_name: ToolName::new("calculator"),
4722            arguments: serde_json::json!({}),
4723        };
4724        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4725
4726        // 1. The typed audit-trail outcome is preserved.
4727        match outcome {
4728            ObservationOutcome::ToolCallSkipped {
4729                provider_call_id,
4730                tool_name,
4731                reason: ToolDispatchSkipReason::NoDispatcher,
4732            } => {
4733                assert_eq!(provider_call_id, "call_unwired");
4734                assert_eq!(tool_name, "calculator");
4735            }
4736            other => panic!("expected ToolCallSkipped/NoDispatcher, got {other:?}"),
4737        }
4738
4739        // 2. The adapter received a SubmitToolError keyed on the same call_id
4740        //    so the provider can complete its response and unblock the live
4741        //    turn. This is the new behavior P2#2 introduces.
4742        let errors = adapter.submitted_errors.lock().unwrap();
4743        assert_eq!(
4744            errors.len(),
4745            1,
4746            "adapter must receive exactly one SubmitToolError when dispatcher is missing"
4747        );
4748        assert_eq!(errors[0].0, "call_unwired");
4749        assert!(
4750            errors[0].1.contains("dispatcher"),
4751            "error message should mention the missing dispatcher; got {:?}",
4752            errors[0].1
4753        );
4754
4755        // 3. No phantom SubmitToolResult was sent.
4756        assert!(adapter.submitted_results.lock().unwrap().is_empty());
4757    }
4758
4759    #[tokio::test]
4760    async fn set_live_tool_dispatcher_late_binds_after_construction() {
4761        // Wave-3 follow-up: rkat-rpc constructs the host before the
4762        // callback channel that backs the dispatcher exists. Pre-set, a
4763        // ToolCallRequested must skip; post-`set_live_tool_dispatcher`, the same
4764        // observation must dispatch through the newly-installed dispatcher.
4765        let sink = Arc::new(RecordingProjectionSink::default());
4766        let dispatcher = Arc::new(RecordingDispatcher::default());
4767        let adapter = Arc::new(RecordingAdapter::default());
4768        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4769        let ch = host
4770            .open_channel_with_generated_test_machine_authority(test_session_id())
4771            .await
4772            .unwrap();
4773        host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4774            .await
4775            .unwrap();
4776        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4777            .await
4778            .unwrap();
4779
4780        let obs = LiveAdapterObservation::ToolCallRequested {
4781            provider_call_id: ToolCallId::new("call_pre"),
4782            tool_name: ToolName::new("calc"),
4783            arguments: serde_json::json!({}),
4784        };
4785        // Phase 1: no dispatcher → skipped.
4786        match host.apply_observation(&ch, &obs).await.unwrap() {
4787            ObservationOutcome::ToolCallSkipped {
4788                reason: ToolDispatchSkipReason::NoDispatcher,
4789                ..
4790            } => {}
4791            other => panic!("expected pre-set skip, got {other:?}"),
4792        }
4793        assert_eq!(dispatcher.calls.lock().unwrap().len(), 0);
4794
4795        // Phase 2: late install → dispatch flows through.
4796        host.set_live_tool_dispatcher(Arc::clone(&dispatcher) as _);
4797        let obs2 = LiveAdapterObservation::ToolCallRequested {
4798            provider_call_id: ToolCallId::new("call_post"),
4799            tool_name: ToolName::new("calc"),
4800            arguments: serde_json::json!({"x": 1}),
4801        };
4802        match host.apply_observation(&ch, &obs2).await.unwrap() {
4803            ObservationOutcome::ToolCallDispatched {
4804                provider_call_id, ..
4805            } => {
4806                assert_eq!(provider_call_id, "call_post");
4807            }
4808            other => panic!("expected post-set dispatch, got {other:?}"),
4809        }
4810        let calls = dispatcher.calls.lock().unwrap();
4811        assert_eq!(calls.len(), 1);
4812        assert_eq!(calls[0].0, "call_post");
4813    }
4814
4815    #[tokio::test]
4816    async fn set_live_tool_dispatcher_replaces_previously_installed_dispatcher() {
4817        // The setter is idempotent on repeated installs and the most recent
4818        // install wins. (In-flight dispatches that already cloned an Arc to
4819        // the prior value are unaffected — that's the documented contract.)
4820        let sink = Arc::new(RecordingProjectionSink::default());
4821        let first = Arc::new(RecordingDispatcher::default());
4822        let second = Arc::new(RecordingDispatcher::default());
4823        let adapter = Arc::new(RecordingAdapter::default());
4824        let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4825            .with_live_tool_dispatcher(Arc::clone(&first) as _);
4826        let ch = host
4827            .open_channel_with_generated_test_machine_authority(test_session_id())
4828            .await
4829            .unwrap();
4830        host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4831            .await
4832            .unwrap();
4833        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4834            .await
4835            .unwrap();
4836
4837        host.set_live_tool_dispatcher(Arc::clone(&second) as _);
4838        let obs = LiveAdapterObservation::ToolCallRequested {
4839            provider_call_id: ToolCallId::new("call_swap"),
4840            tool_name: ToolName::new("calc"),
4841            arguments: serde_json::json!({}),
4842        };
4843        host.apply_observation(&ch, &obs).await.unwrap();
4844
4845        assert_eq!(first.calls.lock().unwrap().len(), 0);
4846        assert_eq!(second.calls.lock().unwrap().len(), 1);
4847    }
4848
4849    #[tokio::test]
4850    async fn tool_call_dispatch_error_submits_tool_error_to_adapter() {
4851        let sink = Arc::new(RecordingProjectionSink::default());
4852        let dispatcher = Arc::new(FailingDispatcher);
4853        let adapter = Arc::new(RecordingAdapter::default());
4854        let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4855            .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _);
4856        let ch = host
4857            .open_channel_with_generated_test_machine_authority(test_session_id())
4858            .await
4859            .unwrap();
4860        host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4861            .await
4862            .unwrap();
4863        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4864            .await
4865            .unwrap();
4866        let obs = LiveAdapterObservation::ToolCallRequested {
4867            provider_call_id: ToolCallId::new("call_err"),
4868            tool_name: ToolName::new("failing"),
4869            arguments: serde_json::json!({}),
4870        };
4871        host.apply_observation(&ch, &obs).await.unwrap();
4872        let errors = adapter.submitted_errors.lock().unwrap();
4873        assert_eq!(errors.len(), 1);
4874        assert_eq!(errors[0].0, "call_err");
4875    }
4876
4877    // -- K61: tool-call dispatch timeout --
4878    //
4879    // Original deleted test: `realtime_tool_timeout`. Pins the contract that a
4880    // dispatcher which holds a tool call past the configured deadline does NOT
4881    // hang the runtime. The host must:
4882    //   1. Submit a typed `LiveAdapterCommand::SubmitToolError` to the adapter
4883    //      so the provider can unblock its turn.
4884    //   2. Return `ObservationOutcome::ToolCallTimedOut` (NOT
4885    //      `ToolCallDispatched`) so the projection layer sees the miss.
4886    //   3. Skip the projection sink — no phantom `ToolDispatched` outcome
4887    //      surfaces to canonical session state.
4888    //
4889    // The deterministic-clock harness (`tokio::time::pause` + `advance`) makes
4890    // the test exercise the real `tokio::time::timeout` boundary without a
4891    // wall-clock dependency, closing the original "needs harness" gap.
4892
4893    /// Tool dispatcher whose `dispatch` future awaits a long sleep before
4894    /// returning. Under `tokio::time::pause()` the sleep never elapses on its
4895    /// own; the test drives the clock past the host's `tool_timeout` to force
4896    /// the timeout branch.
4897    struct SlowDispatcher {
4898        sleep_for: Duration,
4899        calls: StdMutex<u32>,
4900    }
4901
4902    impl SlowDispatcher {
4903        fn new(sleep_for: Duration) -> Self {
4904            Self {
4905                sleep_for,
4906                calls: StdMutex::new(0),
4907            }
4908        }
4909    }
4910
4911    #[async_trait]
4912    impl LiveToolDispatcher for SlowDispatcher {
4913        async fn dispatch_live_tool_call(
4914            &self,
4915            _session_id: &SessionId,
4916            call: ToolCall,
4917        ) -> Result<ToolDispatchOutcome, LiveToolDispatchError> {
4918            *self.calls.lock().unwrap() += 1;
4919            tokio::time::sleep(self.sleep_for).await;
4920            // Echo result so a non-timed-out dispatch still produces a sane outcome.
4921            let tool_result = meerkat_core::types::ToolResult::new(call.id, "ok".into(), false);
4922            Ok(ToolDispatchOutcome::from(tool_result))
4923        }
4924    }
4925
4926    #[tokio::test(start_paused = true)]
4927    async fn realtime_tool_timeout() {
4928        // K61: a dispatcher that takes longer than the host's `tool_timeout`
4929        // must produce a typed `ToolCallTimedOut` outcome and a
4930        // `SubmitToolError` to the adapter — no phantom dispatch result.
4931        let timeout = Duration::from_millis(500);
4932        let dispatcher_sleep = Duration::from_secs(60);
4933        let sink = Arc::new(RecordingProjectionSink::default());
4934        let dispatcher = Arc::new(SlowDispatcher::new(dispatcher_sleep));
4935        let adapter = Arc::new(RecordingAdapter::default());
4936        let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4937            .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _)
4938            .with_tool_timeout(timeout);
4939        let ch = host
4940            .open_channel_with_generated_test_machine_authority(test_session_id())
4941            .await
4942            .unwrap();
4943        host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4944            .await
4945            .unwrap();
4946        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4947            .await
4948            .unwrap();
4949
4950        let obs = LiveAdapterObservation::ToolCallRequested {
4951            provider_call_id: ToolCallId::new("call_slow"),
4952            tool_name: ToolName::new("slow_tool"),
4953            arguments: serde_json::json!({"q": 1}),
4954        };
4955
4956        // Race the host's apply_observation against a clock-advance. Both
4957        // futures are driven by the same tokio runtime under `start_paused`,
4958        // so advancing the clock past the timeout is what unblocks the
4959        // host's `tokio::time::timeout`.
4960        let host_call = async { host.apply_observation(&ch, &obs).await.unwrap() };
4961        let drive_clock = async {
4962            // Yield once so apply_observation gets to the timeout boundary
4963            // before we advance the clock.
4964            tokio::task::yield_now().await;
4965            tokio::time::advance(timeout + Duration::from_millis(1)).await;
4966        };
4967        let (outcome, _) = tokio::join!(host_call, drive_clock);
4968
4969        match outcome {
4970            ObservationOutcome::ToolCallTimedOut {
4971                provider_call_id,
4972                tool_name,
4973                timeout: t,
4974            } => {
4975                assert_eq!(provider_call_id, "call_slow");
4976                assert_eq!(tool_name, "slow_tool");
4977                assert_eq!(t, timeout);
4978            }
4979            other => panic!("expected ToolCallTimedOut, got {other:?}"),
4980        }
4981
4982        // Dispatcher was invoked exactly once.
4983        assert_eq!(*dispatcher.calls.lock().unwrap(), 1);
4984
4985        // Adapter saw a SubmitToolError (NOT a SubmitToolResult).
4986        let errors = adapter.submitted_errors.lock().unwrap();
4987        assert_eq!(errors.len(), 1);
4988        assert_eq!(errors[0].0, "call_slow");
4989        // D281 gate: the adapter-facing payload is exactly the typed
4990        // `LiveToolDispatchTimeout` fact's `Display` projection — not an
4991        // ad-hoc fabricated string. This pins that the typed fact owns the
4992        // truth and the only stringification is the deterministic seam-edge
4993        // render.
4994        assert_eq!(
4995            errors[0].1,
4996            LiveToolDispatchTimeout::new(timeout).to_string(),
4997            "tool dispatch timeout must submit the typed fact's Display projection, \
4998             not a fabricated string: {}",
4999            errors[0].1
5000        );
5001        let results = adapter.submitted_results.lock().unwrap();
5002        assert!(
5003            results.is_empty(),
5004            "no SubmitToolResult should reach the adapter on timeout: {results:?}"
5005        );
5006
5007        // Projection sink saw no spurious tool-related projection.
5008        assert_eq!(sink.text_finals.lock().unwrap().len(), 0);
5009        assert_eq!(sink.transcript_finals.lock().unwrap().len(), 0);
5010        assert_eq!(sink.terminal_errors.lock().unwrap().len(), 0);
5011    }
5012
5013    #[tokio::test(start_paused = true)]
5014    async fn tool_call_dispatch_succeeds_when_within_deadline() {
5015        // K61 companion: with a deadline configured, a dispatcher that
5016        // returns before the deadline must still produce
5017        // `ToolCallDispatched` and submit the result. Pins that the timeout
5018        // wrapper does not change the success path.
5019        let timeout = Duration::from_secs(5);
5020        let dispatcher_sleep = Duration::from_millis(100);
5021        let sink = Arc::new(RecordingProjectionSink::default());
5022        let dispatcher = Arc::new(SlowDispatcher::new(dispatcher_sleep));
5023        let adapter = Arc::new(RecordingAdapter::default());
5024        let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
5025            .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _)
5026            .with_tool_timeout(timeout);
5027        let ch = host
5028            .open_channel_with_generated_test_machine_authority(test_session_id())
5029            .await
5030            .unwrap();
5031        host.attach_adapter(&ch, Arc::clone(&adapter) as _)
5032            .await
5033            .unwrap();
5034        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
5035            .await
5036            .unwrap();
5037
5038        let obs = LiveAdapterObservation::ToolCallRequested {
5039            provider_call_id: ToolCallId::new("call_fast"),
5040            tool_name: ToolName::new("fast_tool"),
5041            arguments: serde_json::json!({}),
5042        };
5043        let host_call = async { host.apply_observation(&ch, &obs).await.unwrap() };
5044        let drive_clock = async {
5045            tokio::task::yield_now().await;
5046            // Advance past dispatcher_sleep but well before timeout.
5047            tokio::time::advance(dispatcher_sleep + Duration::from_millis(10)).await;
5048        };
5049        let (outcome, _) = tokio::join!(host_call, drive_clock);
5050
5051        match outcome {
5052            ObservationOutcome::ToolCallDispatched {
5053                provider_call_id, ..
5054            } => assert_eq!(provider_call_id, "call_fast"),
5055            other => panic!("expected ToolCallDispatched, got {other:?}"),
5056        }
5057        let results = adapter.submitted_results.lock().unwrap();
5058        assert_eq!(results.len(), 1);
5059        assert_eq!(results[0].call_id.0, "call_fast");
5060        assert!(adapter.submitted_errors.lock().unwrap().is_empty());
5061    }
5062
5063    // -- A6: barge-in projection --
5064
5065    #[tokio::test]
5066    async fn barge_in_observation_calls_signal_interrupt() {
5067        let sink = Arc::new(RecordingProjectionSink::default());
5068        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5069        let session_id = test_session_id();
5070        let ch = host
5071            .open_channel_with_generated_test_machine_authority(session_id.clone())
5072            .await
5073            .unwrap();
5074        let outcome = host
5075            .apply_observation(
5076                &ch,
5077                &LiveAdapterObservation::TurnInterrupted {
5078                    response_id: Some("resp_42".into()),
5079                },
5080            )
5081            .await
5082            .unwrap();
5083        assert!(matches!(outcome, ObservationOutcome::InterruptSignalled));
5084        let interrupts = sink.interrupts.lock().unwrap();
5085        assert_eq!(interrupts.len(), 1);
5086        assert_eq!(interrupts[0].0, session_id);
5087        // G4 (P1): the in-flight response id is plumbed through to the sink
5088        // so the truncation can be scoped to the specific response even when
5089        // the barge-in lands before any transcript delta has been staged.
5090        assert_eq!(interrupts[0].1.as_deref(), Some("resp_42"));
5091    }
5092
5093    // -- D223: transport-local barge-in lowers into the interrupt seam --
5094
5095    #[tokio::test]
5096    async fn transport_barge_in_emits_typed_interrupt_via_sink() {
5097        // A transport-discarded output-audio queue (local VAD barge-in) must
5098        // become a typed interrupt fact the session observes — routed through
5099        // the same `signal_turn_interrupt` seam adapter-observed barge-ins use,
5100        // not a silent discard. (Fails-old: there was no host seam for a
5101        // transport-local barge-in, so the discard was log-only.)
5102        let sink = Arc::new(RecordingProjectionSink::default());
5103        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5104        let session_id = test_session_id();
5105        let ch = host
5106            .open_channel_with_generated_test_machine_authority(session_id.clone())
5107            .await
5108            .unwrap();
5109
5110        host.signal_transport_barge_in(&ch).await.unwrap();
5111
5112        let interrupts = sink.interrupts.lock().unwrap();
5113        assert_eq!(
5114            interrupts.len(),
5115            1,
5116            "barge-in must signal exactly one interrupt"
5117        );
5118        assert_eq!(interrupts[0].0, session_id);
5119        // Transport-local barge-in carries no provider response id (same shape
5120        // as the user-facing `live/interrupt` RPC).
5121        assert_eq!(interrupts[0].1, None);
5122    }
5123
5124    // -- K16: transport output-audio delivery degradation lowers into the
5125    //    same host signal seam barge-in uses --
5126
5127    #[tokio::test]
5128    async fn transport_output_audio_degradation_emits_typed_signal_via_sink() {
5129        // Dropped RTP output-audio packets must become a typed, session-
5130        // observable delivery-degraded fact through the projection-sink seam —
5131        // not a transport-local AtomicU64 whose only reader is a test.
5132        // (Fails-old: `LiveProjectionSink` had no degradation signal and the
5133        // host had no lowering path, so the drop count never left the
5134        // transport.)
5135        let sink = Arc::new(RecordingProjectionSink::default());
5136        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5137        let session_id = test_session_id();
5138        let ch = host
5139            .open_channel_with_generated_test_machine_authority(session_id.clone())
5140            .await
5141            .unwrap();
5142
5143        host.signal_output_audio_degraded(&ch, 3).await.unwrap();
5144
5145        {
5146            let degraded = sink.output_audio_degraded.lock().unwrap();
5147            assert_eq!(
5148                degraded.as_slice(),
5149                &[(session_id, 3)],
5150                "degradation must surface exactly once with the cumulative drop count"
5151            );
5152        }
5153
5154        // An unknown channel is a typed rejection, not a silent no-op.
5155        let missing = host
5156            .signal_output_audio_degraded(&LiveChannelId::new("no-such-channel"), 1)
5157            .await;
5158        assert!(matches!(
5159            missing,
5160            Err(LiveAdapterHostError::ChannelNotFound(_))
5161        ));
5162    }
5163
5164    // -- A10: terminal error projection --
5165
5166    #[tokio::test]
5167    async fn terminal_error_observation_signals_sink_without_closing_host_directly() {
5168        let sink = Arc::new(RecordingProjectionSink::default());
5169        let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5170        let session_id = test_session_id();
5171        let ch = host
5172            .open_channel_with_generated_test_machine_authority(session_id.clone())
5173            .await
5174            .unwrap();
5175        let obs = LiveAdapterObservation::Error {
5176            code: LiveAdapterErrorCode::ConnectionLost,
5177            message: "ws closed unexpectedly".into(),
5178        };
5179        let err = host
5180            .apply_observation(&ch, &obs)
5181            .await
5182            .expect_err("terminal error must wait for generated close authority");
5183        assert!(matches!(err, LiveAdapterHostError::CloseNotAuthorized));
5184        assert_eq!(
5185            host.channel_status(&ch).await.unwrap(),
5186            LiveAdapterStatus::Opening
5187        );
5188        assert_eq!(sink.terminal_errors.lock().unwrap().len(), 0);
5189
5190        let close = host
5191            .close_channel_observed_with_generated_test_machine_authority(&ch)
5192            .await
5193            .unwrap();
5194        assert_eq!(close.channel_id(), ch.as_str());
5195
5196        let outcome = host.apply_observation(&ch, &obs).await.unwrap();
5197        match outcome {
5198            ObservationOutcome::Terminal {
5199                code: LiveAdapterErrorCode::ConnectionLost,
5200            } => {}
5201            other => panic!("expected Terminal/ConnectionLost, got {other:?}"),
5202        }
5203        let status = host.channel_status(&ch).await.unwrap();
5204        assert_eq!(status, LiveAdapterStatus::Closed);
5205        // Sink was signalled.
5206        let terminals = sink.terminal_errors.lock().unwrap();
5207        assert_eq!(terminals.len(), 1);
5208        assert_eq!(terminals[0].0, session_id);
5209        assert!(matches!(
5210            terminals[0].1,
5211            LiveAdapterErrorCode::ConnectionLost
5212        ));
5213    }
5214
5215    // -- N80: O(1) session lookup correctness check --
5216
5217    #[tokio::test]
5218    async fn duplicate_session_check_uses_reverse_map() {
5219        // Verifies that the reverse map honors the same invariant as the
5220        // prior linear scan: a closed-but-not-yet-reaped channel does not
5221        // block re-binding the same session.
5222        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
5223        let s = test_session_id();
5224        let ch = host
5225            .open_channel_with_generated_test_machine_authority(s.clone())
5226            .await
5227            .unwrap();
5228        host.close_channel_with_generated_test_machine_authority(&ch)
5229            .await
5230            .unwrap();
5231        // Closed channel still in retention map; rebind must succeed.
5232        host.open_channel_with_generated_test_machine_authority(s)
5233            .await
5234            .unwrap();
5235    }
5236
5237    #[tokio::test]
5238    async fn transport_send_input_does_not_mint_command_acceptance_evidence() {
5239        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
5240        let ch = host
5241            .open_channel_with_generated_test_machine_authority(test_session_id())
5242            .await
5243            .unwrap();
5244        host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
5245            .await
5246            .unwrap();
5247        host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
5248            .await
5249            .unwrap();
5250
5251        host.send_input(
5252            &ch,
5253            LiveInputChunk::Text {
5254                text: "hello".into(),
5255            },
5256        )
5257        .await
5258        .unwrap();
5259        {
5260            let inner = host.inner.lock().await;
5261            let channel = inner.channels.get(&ch).unwrap();
5262            assert_eq!(channel.command_acceptance_sequence, 0);
5263        }
5264
5265        let acceptance = host
5266            .send_input_observed(
5267                &ch,
5268                LiveInputChunk::Text {
5269                    text: "hello".into(),
5270                },
5271            )
5272            .await
5273            .unwrap();
5274        assert_eq!(acceptance.kind(), LiveCommandAcceptanceKind::SendInput);
5275        assert_eq!(acceptance.acceptance_sequence(), 1);
5276    }
5277
5278    #[tokio::test]
5279    async fn rejected_live_command_does_not_mint_acceptance_evidence() {
5280        let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
5281        let ch = host
5282            .open_channel_with_generated_test_machine_authority(test_session_id())
5283            .await
5284            .unwrap();
5285        host.attach_adapter(&ch, Arc::new(RejectingCommandAdapter))
5286            .await
5287            .unwrap();
5288
5289        let result = host
5290            .send_command_observed(&ch, LiveAdapterCommand::Interrupt)
5291            .await;
5292
5293        assert!(
5294            matches!(
5295                result,
5296                Err(LiveAdapterHostError::AdapterError(
5297                    LiveAdapterError::TransportError { .. }
5298                ))
5299            ),
5300            "adapter rejection must surface before public command acceptance"
5301        );
5302        let inner = host.inner.lock().await;
5303        let channel = inner.channels.get(&ch).unwrap();
5304        assert_eq!(
5305            channel.command_acceptance_sequence, 0,
5306            "rejected command must not mint authority evidence that WebRTC could use to discard output"
5307        );
5308    }
5309
5310    // ---------------------------------------------------------------------
5311    // Test fakes
5312    // ---------------------------------------------------------------------
5313
5314    struct StubAdapter;
5315
5316    impl StubAdapter {
5317        fn new() -> Self {
5318            Self
5319        }
5320    }
5321
5322    #[derive(Default)]
5323    struct FailOnceCloseAdapter {
5324        closes: std::sync::atomic::AtomicUsize,
5325    }
5326
5327    #[async_trait]
5328    impl LiveAdapter for FailOnceCloseAdapter {
5329        async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5330            Ok(())
5331        }
5332
5333        async fn next_observation(
5334            &self,
5335        ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5336            Ok(None)
5337        }
5338
5339        fn status(&self) -> LiveAdapterStatus {
5340            LiveAdapterStatus::Ready
5341        }
5342
5343        async fn close(&self) -> Result<(), LiveAdapterError> {
5344            if self
5345                .closes
5346                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
5347                == 0
5348            {
5349                Err(LiveAdapterError::TransportError {
5350                    message: "scripted first physical close failure".to_string(),
5351                })
5352            } else {
5353                Ok(())
5354            }
5355        }
5356    }
5357
5358    #[async_trait]
5359    impl LiveAdapter for StubAdapter {
5360        async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5361            Ok(())
5362        }
5363
5364        async fn next_observation(
5365            &self,
5366        ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5367            Ok(None)
5368        }
5369
5370        fn status(&self) -> LiveAdapterStatus {
5371            LiveAdapterStatus::Ready
5372        }
5373
5374        async fn close(&self) -> Result<(), LiveAdapterError> {
5375            Ok(())
5376        }
5377    }
5378
5379    /// Adapter whose pump always errors.
5380    struct ErroringAdapter;
5381
5382    #[async_trait]
5383    impl LiveAdapter for ErroringAdapter {
5384        async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5385            Ok(())
5386        }
5387
5388        async fn next_observation(
5389            &self,
5390        ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5391            Err(LiveAdapterError::TransportError {
5392                message: "pump dead".into(),
5393            })
5394        }
5395
5396        fn status(&self) -> LiveAdapterStatus {
5397            LiveAdapterStatus::Closed
5398        }
5399
5400        async fn close(&self) -> Result<(), LiveAdapterError> {
5401            Ok(())
5402        }
5403    }
5404
5405    struct RejectingCommandAdapter;
5406
5407    #[async_trait]
5408    impl LiveAdapter for RejectingCommandAdapter {
5409        async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5410            Err(LiveAdapterError::TransportError {
5411                message: "command queue rejected".into(),
5412            })
5413        }
5414
5415        async fn next_observation(
5416            &self,
5417        ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5418            Ok(None)
5419        }
5420
5421        fn status(&self) -> LiveAdapterStatus {
5422            LiveAdapterStatus::Ready
5423        }
5424
5425        async fn close(&self) -> Result<(), LiveAdapterError> {
5426            Ok(())
5427        }
5428    }
5429
5430    /// Adapter that records `SubmitToolResult` / `SubmitToolError` so tests
5431    /// can verify the round-trip from observation → dispatch → submit.
5432    #[derive(Default)]
5433    struct RecordingAdapter {
5434        submitted_results: StdMutex<Vec<LiveToolResult>>,
5435        submitted_errors: StdMutex<Vec<(String, String)>>,
5436    }
5437
5438    #[async_trait]
5439    impl LiveAdapter for RecordingAdapter {
5440        async fn send_command(&self, command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5441            match command {
5442                LiveAdapterCommand::SubmitToolResult { result } => {
5443                    self.submitted_results.lock().unwrap().push(result);
5444                }
5445                LiveAdapterCommand::SubmitToolError { call_id, error } => {
5446                    self.submitted_errors
5447                        .lock()
5448                        .unwrap()
5449                        .push((call_id.0, error));
5450                }
5451                _ => {}
5452            }
5453            Ok(())
5454        }
5455
5456        async fn next_observation(
5457            &self,
5458        ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5459            Ok(None)
5460        }
5461
5462        fn status(&self) -> LiveAdapterStatus {
5463            LiveAdapterStatus::Ready
5464        }
5465
5466        async fn close(&self) -> Result<(), LiveAdapterError> {
5467            Ok(())
5468        }
5469    }
5470
5471    /// Tool dispatcher fake that records the calls it sees and returns an
5472    /// echo result so the host can submit it back to the adapter.
5473    #[derive(Default)]
5474    struct RecordingDispatcher {
5475        /// (call_id, tool_name, args_json)
5476        calls: StdMutex<Vec<(String, String, String)>>,
5477    }
5478
5479    #[async_trait]
5480    impl LiveToolDispatcher for RecordingDispatcher {
5481        async fn dispatch_live_tool_call(
5482            &self,
5483            _session_id: &SessionId,
5484            call: ToolCall,
5485        ) -> Result<ToolDispatchOutcome, LiveToolDispatchError> {
5486            self.calls.lock().unwrap().push((
5487                call.id.clone(),
5488                call.name.clone(),
5489                call.args.to_string(),
5490            ));
5491            let tool_result = meerkat_core::types::ToolResult::new(call.id, "ok".into(), false);
5492            Ok(ToolDispatchOutcome::from(tool_result))
5493        }
5494    }
5495
5496    /// Tool dispatcher that always errors.
5497    struct FailingDispatcher;
5498
5499    #[async_trait]
5500    impl LiveToolDispatcher for FailingDispatcher {
5501        async fn dispatch_live_tool_call(
5502            &self,
5503            _session_id: &SessionId,
5504            _call: ToolCall,
5505        ) -> Result<ToolDispatchOutcome, LiveToolDispatchError> {
5506            Err(LiveToolDispatchError::Tool(
5507                meerkat_core::error::ToolError::ExecutionFailed {
5508                    message: "bang".into(),
5509                },
5510            ))
5511        }
5512    }
5513
5514    /// Owned mirror of [`LiveTranscriptIdentity`] used by the recording sink
5515    /// so tests can keep captured rows past the synchronous trait callsite.
5516    #[derive(Debug, Clone, Default, PartialEq, Eq)]
5517    struct OwnedIdentity {
5518        provider_item_id: Option<String>,
5519        previous_item_id: Option<String>,
5520        content_index: Option<u32>,
5521        response_id: Option<String>,
5522        delta_id: Option<String>,
5523    }
5524
5525    impl OwnedIdentity {
5526        fn from_borrowed(identity: LiveTranscriptIdentity<'_>) -> Self {
5527            Self {
5528                provider_item_id: identity.provider_item_id.map(|s| s.to_string()),
5529                previous_item_id: identity.previous_item_id.map(|s| s.to_string()),
5530                content_index: identity.content_index,
5531                response_id: identity.response_id.map(|s| s.to_string()),
5532                delta_id: identity.delta_id.map(|s| s.to_string()),
5533            }
5534        }
5535    }
5536
5537    /// Records every projection sink call so tests can assert routing
5538    /// decisions deterministically. Identity is captured in full so tests
5539    /// can pin A11's end-to-end identity preservation.
5540    #[derive(Default)]
5541    #[allow(clippy::type_complexity)]
5542    struct RecordingProjectionSink {
5543        user_transcripts: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
5544        // T6: split text-lane and transcript-lane recording so tests can
5545        // assert the host routed the right observation to the right method.
5546        text_deltas: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
5547        transcript_deltas: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
5548        text_finals: StdMutex<
5549            Vec<(
5550                SessionId,
5551                String,
5552                OwnedIdentity,
5553                StopReason,
5554                Usage,
5555                Option<String>,
5556            )>,
5557        >,
5558        transcript_finals: StdMutex<
5559            Vec<(
5560                SessionId,
5561                String,
5562                OwnedIdentity,
5563                StopReason,
5564                Usage,
5565                Option<String>,
5566            )>,
5567        >,
5568        truncations: StdMutex<
5569            Vec<(
5570                SessionId,
5571                Option<String>,
5572                Option<String>,
5573                Option<u32>,
5574                Option<String>,
5575                Option<String>,
5576            )>,
5577        >,
5578        interrupts: StdMutex<Vec<(SessionId, Option<String>)>>,
5579        output_audio_degraded: StdMutex<Vec<(SessionId, u64)>>,
5580        turn_completed: StdMutex<Vec<(SessionId, StopReason, Usage, Option<String>)>>,
5581        terminal_errors: StdMutex<Vec<(SessionId, LiveAdapterErrorCode, String)>>,
5582        realtime_events: StdMutex<Vec<(SessionId, RealtimeTranscriptEvent)>>,
5583    }
5584
5585    #[async_trait]
5586    impl LiveProjectionSink for RecordingProjectionSink {
5587        async fn append_user_transcript(
5588            &self,
5589            session_id: &SessionId,
5590            text: &str,
5591            identity: LiveTranscriptIdentity<'_>,
5592        ) -> Result<(), LiveProjectionError> {
5593            self.user_transcripts.lock().unwrap().push((
5594                session_id.clone(),
5595                text.to_string(),
5596                OwnedIdentity::from_borrowed(identity),
5597            ));
5598            Ok(())
5599        }
5600
5601        async fn append_assistant_text_delta(
5602            &self,
5603            session_id: &SessionId,
5604            delta: &str,
5605            identity: LiveTranscriptIdentity<'_>,
5606        ) -> Result<(), LiveProjectionError> {
5607            self.text_deltas.lock().unwrap().push((
5608                session_id.clone(),
5609                delta.to_string(),
5610                OwnedIdentity::from_borrowed(identity),
5611            ));
5612            Ok(())
5613        }
5614
5615        async fn append_assistant_transcript_delta(
5616            &self,
5617            session_id: &SessionId,
5618            delta: &str,
5619            identity: LiveTranscriptIdentity<'_>,
5620        ) -> Result<(), LiveProjectionError> {
5621            self.transcript_deltas.lock().unwrap().push((
5622                session_id.clone(),
5623                delta.to_string(),
5624                OwnedIdentity::from_borrowed(identity),
5625            ));
5626            Ok(())
5627        }
5628
5629        async fn append_assistant_text_final(
5630            &self,
5631            session_id: &SessionId,
5632            text: &str,
5633            identity: LiveTranscriptIdentity<'_>,
5634            stop_reason: StopReason,
5635            usage: Usage,
5636            response_id: Option<&str>,
5637        ) -> Result<(), LiveProjectionError> {
5638            self.text_finals.lock().unwrap().push((
5639                session_id.clone(),
5640                text.to_string(),
5641                OwnedIdentity::from_borrowed(identity),
5642                stop_reason,
5643                usage,
5644                response_id.map(|s| s.to_string()),
5645            ));
5646            Ok(())
5647        }
5648
5649        async fn append_assistant_transcript_final(
5650            &self,
5651            session_id: &SessionId,
5652            text: &str,
5653            identity: LiveTranscriptIdentity<'_>,
5654            stop_reason: StopReason,
5655            usage: Usage,
5656            response_id: Option<&str>,
5657        ) -> Result<(), LiveProjectionError> {
5658            self.transcript_finals.lock().unwrap().push((
5659                session_id.clone(),
5660                text.to_string(),
5661                OwnedIdentity::from_borrowed(identity),
5662                stop_reason,
5663                usage,
5664                response_id.map(|s| s.to_string()),
5665            ));
5666            Ok(())
5667        }
5668
5669        async fn truncate_assistant_transcript(
5670            &self,
5671            session_id: &SessionId,
5672            provider_item_id: Option<&str>,
5673            previous_item_id: Option<&str>,
5674            content_index: Option<u32>,
5675            response_id: Option<&str>,
5676            text: Option<&str>,
5677        ) -> Result<(), LiveProjectionError> {
5678            self.truncations.lock().unwrap().push((
5679                session_id.clone(),
5680                provider_item_id.map(|s| s.to_string()),
5681                previous_item_id.map(|s| s.to_string()),
5682                content_index,
5683                response_id.map(|s| s.to_string()),
5684                text.map(|s| s.to_string()),
5685            ));
5686            Ok(())
5687        }
5688
5689        async fn signal_turn_interrupt(
5690            &self,
5691            session_id: &SessionId,
5692            response_id: Option<&str>,
5693        ) -> Result<(), LiveProjectionError> {
5694            self.interrupts
5695                .lock()
5696                .unwrap()
5697                .push((session_id.clone(), response_id.map(|s| s.to_string())));
5698            Ok(())
5699        }
5700
5701        async fn signal_output_audio_degraded(
5702            &self,
5703            session_id: &SessionId,
5704            dropped: u64,
5705        ) -> Result<(), LiveProjectionError> {
5706            self.output_audio_degraded
5707                .lock()
5708                .unwrap()
5709                .push((session_id.clone(), dropped));
5710            Ok(())
5711        }
5712
5713        async fn signal_turn_completed(
5714            &self,
5715            session_id: &SessionId,
5716            stop_reason: StopReason,
5717            usage: Usage,
5718            response_id: Option<&str>,
5719        ) -> Result<(), LiveProjectionError> {
5720            self.turn_completed.lock().unwrap().push((
5721                session_id.clone(),
5722                stop_reason,
5723                usage,
5724                response_id.map(|s| s.to_string()),
5725            ));
5726            Ok(())
5727        }
5728
5729        async fn signal_terminal_error(
5730            &self,
5731            session_id: &SessionId,
5732            code: LiveAdapterErrorCode,
5733            message: &str,
5734        ) -> Result<(), LiveProjectionError> {
5735            self.terminal_errors.lock().unwrap().push((
5736                session_id.clone(),
5737                code,
5738                message.to_string(),
5739            ));
5740            Ok(())
5741        }
5742
5743        async fn append_realtime_transcript(
5744            &self,
5745            session_id: &SessionId,
5746            event: &RealtimeTranscriptEvent,
5747        ) -> Result<RealtimeTranscriptApplyOutcome, LiveProjectionError> {
5748            self.realtime_events
5749                .lock()
5750                .unwrap()
5751                .push((session_id.clone(), event.clone()));
5752            Ok(RealtimeTranscriptApplyOutcome::default())
5753        }
5754    }
5755
5756    // ---------------------------------------------------------------------
5757    // D301: typed SessionError -> LiveProjectionError classification
5758    // ---------------------------------------------------------------------
5759
5760    #[test]
5761    fn session_error_classification_lands_in_distinct_typed_variants() {
5762        use meerkat_core::SessionError;
5763
5764        let sid = test_session_id();
5765
5766        // NotFound -> SessionNotFound (identity-bearing).
5767        assert!(matches!(
5768            LiveProjectionError::from_session_error(
5769                &sid,
5770                SessionError::NotFound { id: sid.clone() }
5771            ),
5772            LiveProjectionError::SessionNotFound(_)
5773        ));
5774
5775        // Unsupported -> Rejected, preserving the typed reason payload.
5776        match LiveProjectionError::from_session_error(
5777            &sid,
5778            SessionError::Unsupported("nope".into()),
5779        ) {
5780            LiveProjectionError::Rejected(reason) => assert_eq!(reason, "nope"),
5781            other => panic!("expected Rejected, got {other:?}"),
5782        }
5783
5784        // Busy -> SessionBusy (distinct from Internal/Session).
5785        assert!(matches!(
5786            LiveProjectionError::from_session_error(&sid, SessionError::Busy { id: sid.clone() }),
5787            LiveProjectionError::SessionBusy(_)
5788        ));
5789
5790        // NotRunning -> SessionNotRunning.
5791        assert!(matches!(
5792            LiveProjectionError::from_session_error(
5793                &sid,
5794                SessionError::NotRunning { id: sid.clone() }
5795            ),
5796            LiveProjectionError::SessionNotRunning(_)
5797        ));
5798
5799        // PersistenceDisabled / CompactionDisabled -> CapabilityDisabled,
5800        // carrying the stable code (no prose-only Internal collapse).
5801        match LiveProjectionError::from_session_error(&sid, SessionError::PersistenceDisabled) {
5802            LiveProjectionError::CapabilityDisabled { code, .. } => {
5803                assert_eq!(code, "SESSION_PERSISTENCE_DISABLED");
5804            }
5805            other => panic!("expected CapabilityDisabled, got {other:?}"),
5806        }
5807        match LiveProjectionError::from_session_error(&sid, SessionError::CompactionDisabled) {
5808            LiveProjectionError::CapabilityDisabled { code, .. } => {
5809                assert_eq!(code, "SESSION_COMPACTION_DISABLED");
5810            }
5811            other => panic!("expected CapabilityDisabled, got {other:?}"),
5812        }
5813
5814        // Store error -> typed Session { code } carrying the stable code,
5815        // NOT a prose-only Internal(to_string()).
5816        let store_err: Box<dyn std::error::Error + Send + Sync> = "disk gone".into();
5817        match LiveProjectionError::from_session_error(&sid, SessionError::Store(store_err)) {
5818            LiveProjectionError::Session { code, .. } => {
5819                assert_eq!(code, "SESSION_STORE_ERROR");
5820            }
5821            other => panic!("expected typed Session, got {other:?}"),
5822        }
5823
5824        // FailedWithData -> typed Session { code } (still carries the stable
5825        // code; the prose lives only in `message`).
5826        match LiveProjectionError::from_session_error(
5827            &sid,
5828            SessionError::FailedWithData {
5829                message: "boom".into(),
5830                data: serde_json::json!({"k": "v"}),
5831            },
5832        ) {
5833            LiveProjectionError::Session { code, message } => {
5834                assert_eq!(code, "SESSION_ERROR");
5835                assert_eq!(message, "boom");
5836            }
5837            other => panic!("expected typed Session, got {other:?}"),
5838        }
5839    }
5840
5841    // ---------------------------------------------------------------------
5842    // D113: typed SessionError -> LiveToolDispatchError classification.
5843    // A NotFound / busy / not-running / capability-disabled / unsupported /
5844    // store SessionError maps to the matching typed LiveToolDispatchError
5845    // variant, never a generic Internal(to_string()) string, so the terminal
5846    // class survives back through the live adapter.
5847    // ---------------------------------------------------------------------
5848
5849    #[test]
5850    fn live_tool_dispatch_session_error_classification_lands_in_distinct_typed_variants() {
5851        use meerkat_core::SessionError;
5852
5853        let sid = test_session_id();
5854
5855        // NotFound -> SessionNotFound (identity-bearing), NOT a string.
5856        assert!(matches!(
5857            LiveToolDispatchError::from_session_error(
5858                &sid,
5859                SessionError::NotFound { id: sid.clone() }
5860            ),
5861            LiveToolDispatchError::SessionNotFound(_)
5862        ));
5863
5864        // Unsupported (no-dispatcher / refusal) -> Rejected, preserving the
5865        // typed reason payload.
5866        match LiveToolDispatchError::from_session_error(
5867            &sid,
5868            SessionError::Unsupported("no live tool dispatcher".into()),
5869        ) {
5870            LiveToolDispatchError::Rejected(reason) => {
5871                assert_eq!(reason, "no live tool dispatcher");
5872            }
5873            other => panic!("expected Rejected, got {other:?}"),
5874        }
5875
5876        // Busy -> SessionBusy (distinct from Internal/Session).
5877        assert!(matches!(
5878            LiveToolDispatchError::from_session_error(&sid, SessionError::Busy { id: sid.clone() }),
5879            LiveToolDispatchError::SessionBusy(_)
5880        ));
5881
5882        // NotRunning -> SessionNotRunning.
5883        assert!(matches!(
5884            LiveToolDispatchError::from_session_error(
5885                &sid,
5886                SessionError::NotRunning { id: sid.clone() }
5887            ),
5888            LiveToolDispatchError::SessionNotRunning(_)
5889        ));
5890
5891        // PersistenceDisabled / CompactionDisabled -> CapabilityDisabled,
5892        // carrying the stable code (no prose-only Internal collapse).
5893        match LiveToolDispatchError::from_session_error(&sid, SessionError::PersistenceDisabled) {
5894            LiveToolDispatchError::CapabilityDisabled { code, .. } => {
5895                assert_eq!(code, "SESSION_PERSISTENCE_DISABLED");
5896            }
5897            other => panic!("expected CapabilityDisabled, got {other:?}"),
5898        }
5899
5900        // Store error -> typed Session { code } carrying the stable code,
5901        // NOT a prose-only Internal(to_string()).
5902        let store_err: Box<dyn std::error::Error + Send + Sync> = "disk gone".into();
5903        match LiveToolDispatchError::from_session_error(&sid, SessionError::Store(store_err)) {
5904            LiveToolDispatchError::Session { code, .. } => {
5905                assert_eq!(code, "SESSION_STORE_ERROR");
5906            }
5907            other => panic!("expected typed Session, got {other:?}"),
5908        }
5909    }
5910
5911    // ---------------------------------------------------------------------
5912    // D199: transcript delta identity fails closed on missing required ids
5913    // ---------------------------------------------------------------------
5914
5915    #[test]
5916    fn delta_identity_requires_response_delta_and_item_ids() {
5917        // Full identity resolves to a typed triple.
5918        let full = LiveTranscriptIdentity::assistant_delta(
5919            Some("item-1"),
5920            Some("prev-0"),
5921            Some(2),
5922            Some("resp-9"),
5923            Some("delta-3"),
5924        );
5925        let resolved = full
5926            .require_delta_identity()
5927            .expect("full identity resolves");
5928        assert_eq!(resolved.response_id, "resp-9");
5929        assert_eq!(resolved.delta_id, "delta-3");
5930        assert_eq!(resolved.item_id, "item-1");
5931        assert_eq!(resolved.previous_item_id, Some("prev-0"));
5932        assert_eq!(resolved.content_index, Some(2));
5933
5934        // Missing response_id fails closed with a typed error (NOT an
5935        // empty-string-coalesced identity).
5936        let no_resp = LiveTranscriptIdentity::assistant_delta(
5937            Some("item-1"),
5938            None,
5939            Some(0),
5940            None,
5941            Some("delta-3"),
5942        );
5943        assert_eq!(
5944            no_resp.require_delta_identity(),
5945            Err(LiveTranscriptIdentityError::MissingResponseId)
5946        );
5947
5948        // Missing delta_id fails closed.
5949        let no_delta = LiveTranscriptIdentity::assistant_delta(
5950            Some("item-1"),
5951            None,
5952            Some(0),
5953            Some("resp-9"),
5954            None,
5955        );
5956        assert_eq!(
5957            no_delta.require_delta_identity(),
5958            Err(LiveTranscriptIdentityError::MissingDeltaId)
5959        );
5960
5961        // Missing item_id fails closed.
5962        let no_item = LiveTranscriptIdentity::assistant_delta(
5963            None,
5964            None,
5965            Some(0),
5966            Some("resp-9"),
5967            Some("delta-3"),
5968        );
5969        assert_eq!(
5970            no_item.require_delta_identity(),
5971            Err(LiveTranscriptIdentityError::MissingItemId)
5972        );
5973    }
5974}