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