1use 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#[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 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
131pub enum LiveCommandAcceptanceKind {
132 SendInput,
133 CommitInput,
134 Interrupt,
135 TruncateAssistantOutput,
136}
137
138#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
402#[non_exhaustive]
403pub enum ObservationOutcome {
404 Noop,
406 StatusUpdated(LiveAdapterStatus),
408 TranscriptAppended,
410 TranscriptTruncated,
412 ToolCallDispatched {
414 provider_call_id: String,
415 tool_name: String,
416 },
417 ToolCallSkipped {
419 provider_call_id: String,
420 tool_name: String,
421 reason: ToolDispatchSkipReason,
422 },
423 ToolCallTimedOut {
429 provider_call_id: String,
430 tool_name: String,
431 timeout: std::time::Duration,
432 },
433 InterruptSignalled,
435 Terminal { code: LiveAdapterErrorCode },
437 CommandRejected {
444 code: LiveAdapterErrorCode,
445 message: String,
446 },
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
453#[non_exhaustive]
454pub enum ToolDispatchSkipReason {
455 NoDispatcher,
456 InvalidArguments,
457}
458
459#[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#[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 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 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 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 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#[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#[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 async fn append_user_transcript(
647 &self,
648 session_id: &SessionId,
649 text: &str,
650 identity: LiveTranscriptIdentity<'_>,
651 ) -> Result<(), LiveProjectionError>;
652
653 async fn append_assistant_text_delta(
661 &self,
662 session_id: &SessionId,
663 delta: &str,
664 identity: LiveTranscriptIdentity<'_>,
665 ) -> Result<(), LiveProjectionError>;
666
667 async fn append_assistant_transcript_delta(
676 &self,
677 session_id: &SessionId,
678 delta: &str,
679 identity: LiveTranscriptIdentity<'_>,
680 ) -> Result<(), LiveProjectionError>;
681
682 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 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 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 async fn signal_turn_interrupt(
746 &self,
747 session_id: &SessionId,
748 response_id: Option<&str>,
749 ) -> Result<(), LiveProjectionError>;
750
751 async fn signal_output_audio_degraded(
760 &self,
761 session_id: &SessionId,
762 dropped: u64,
763 ) -> Result<(), LiveProjectionError>;
764
765 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 async fn signal_terminal_error(
782 &self,
783 session_id: &SessionId,
784 code: LiveAdapterErrorCode,
785 message: &str,
786 ) -> Result<(), LiveProjectionError>;
787
788 async fn append_realtime_transcript(
803 &self,
804 session_id: &SessionId,
805 event: &RealtimeTranscriptEvent,
806 ) -> Result<(), LiveProjectionError>;
807}
808
809#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
819#[non_exhaustive]
820pub enum LiveProjectionError {
821 #[error("session {0} not found")]
822 SessionNotFound(SessionId),
823 #[error("projection rejected: {0}")]
826 Rejected(String),
827 #[error("session busy: {0}")]
830 SessionBusy(SessionId),
831 #[error("session not running: {0}")]
834 SessionNotRunning(SessionId),
835 #[error("session capability disabled ({code}): {message}")]
838 CapabilityDisabled { code: &'static str, message: String },
839 #[error("session error [{code}]: {message}")]
843 Session { code: &'static str, message: String },
844}
845
846impl LiveProjectionError {
847 #[must_use]
857 pub fn from_session_error(session_id: &SessionId, err: meerkat_core::SessionError) -> Self {
858 use meerkat_core::SessionError;
859 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 SessionError::Store(_)
880 | SessionError::Agent(_)
881 | SessionError::FailedWithData { .. } => Self::Session { code, message },
882 }
883 }
884}
885
886#[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 async fn append_realtime_transcript(
1010 &self,
1011 _session_id: &SessionId,
1012 _event: &RealtimeTranscriptEvent,
1013 ) -> Result<(), LiveProjectionError> {
1014 Ok(())
1015 }
1016}
1017
1018const CLOSED_CHANNEL_TTL: std::time::Duration = std::time::Duration::from_secs(60);
1026
1027struct ChannelState {
1029 session_id: SessionId,
1030 status: LiveAdapterStatus,
1034 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 retire_at: Option<std::time::Instant>,
1050 pending_synthetic_obs: Option<LiveAdapterObservation>,
1067}
1068
1069#[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#[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 #[error(transparent)]
1357 AdapterError(#[from] LiveAdapterError),
1358 #[error("projection sink error: {0}")]
1359 ProjectionError(#[from] LiveProjectionError),
1360}
1361
1362impl LiveAdapterHostError {
1363 #[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#[derive(Debug, Clone, PartialEq)]
1397#[non_exhaustive]
1398pub enum ObservationRouting {
1399 AppendTranscript,
1400 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 CommandRejection,
1419 Noop,
1420}
1421
1422#[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#[derive(Debug, Clone, thiserror::Error)]
1449#[non_exhaustive]
1450pub enum LiveToolDispatchError {
1451 #[error(transparent)]
1452 Tool(#[from] ToolError),
1453 #[error("live tool dispatch target session {0} not found")]
1455 SessionNotFound(SessionId),
1456 #[error("live tool dispatch target session {0} is busy")]
1459 SessionBusy(SessionId),
1460 #[error("live tool dispatch target session {0} is not running")]
1463 SessionNotRunning(SessionId),
1464 #[error("live tool dispatch capability disabled ({code}): {message}")]
1467 CapabilityDisabled { code: &'static str, message: String },
1468 #[error("live tool dispatch rejected: {0}")]
1471 Rejected(String),
1472 #[error("live tool dispatch session error [{code}]: {message}")]
1476 Session { code: &'static str, message: String },
1477}
1478
1479impl LiveToolDispatchError {
1480 #[must_use]
1492 pub fn from_session_error(session_id: &SessionId, err: meerkat_core::SessionError) -> Self {
1493 use meerkat_core::SessionError;
1494 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 SessionError::Store(_)
1515 | SessionError::Agent(_)
1516 | SessionError::FailedWithData { .. } => Self::Session { code, message },
1517 }
1518 }
1519}
1520
1521pub struct LiveAdapterHost {
1530 inner: Mutex<HostInner>,
1531 projection_sink: Arc<dyn LiveProjectionSink>,
1546 tool_dispatcher: std::sync::Mutex<Option<Arc<dyn LiveToolDispatcher>>>,
1554 tool_timeout: Duration,
1568}
1569
1570pub const DEFAULT_LIVE_TOOL_TIMEOUT: Duration = Duration::from_secs(30);
1576
1577struct HostInner {
1578 channels: IndexMap<LiveChannelId, ChannelState>,
1581 by_session: HashMap<SessionId, LiveChannelId>,
1584}
1585
1586impl LiveAdapterHost {
1587 #[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 #[must_use]
1621 pub fn with_tool_timeout(mut self, timeout: Duration) -> Self {
1622 self.tool_timeout = timeout;
1623 self
1624 }
1625
1626 #[must_use]
1628 pub fn tool_timeout(&self) -> Duration {
1629 self.tool_timeout
1630 }
1631
1632 #[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 pub fn set_live_tool_dispatcher(&self, dispatcher: Arc<dyn LiveToolDispatcher>) {
1649 if let Ok(mut slot) = self.tool_dispatcher.lock() {
1653 *slot = Some(dispatcher);
1654 }
1655 }
1661
1662 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 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 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 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 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, false)
1811 .await?;
1812 adapter.send_command(command).await?;
1813 Ok(())
1814 }
1815
1816 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, false)
1831 .await?;
1832 adapter.send_command(command).await?;
1833 self.record_command_queue_acceptance(channel_id, acceptance_kind)
1834 .await
1835 }
1836
1837 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, 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 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, true)
1879 .await?;
1880 adapter
1881 .send_command(LiveAdapterCommand::SendInput { chunk })
1882 .await?;
1883 Ok(())
1884 }
1885
1886 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, 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 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 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 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 pub async fn next_observation_raw(
1961 &self,
1962 channel_id: &LiveChannelId,
1963 ) -> Result<Option<LiveAdapterObservation>, LiveAdapterHostError> {
1964 {
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, false)
1982 .await?;
1983 match adapter.next_observation().await {
1984 Ok(Some(obs)) => Ok(Some(obs)),
1985 Ok(None) => {
1986 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 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 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 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 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 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 (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 (
2248 ObservationRouting::CommandRejection,
2249 LiveAdapterObservation::CommandRejected { code, message },
2250 ) => Ok(ObservationOutcome::CommandRejected {
2251 code: code.clone(),
2252 message: message.clone(),
2253 }),
2254
2255 (ObservationRouting::AppendTranscript, _) => Ok(ObservationOutcome::Noop),
2260
2261 _ => 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 inner
2915 .channels
2916 .iter()
2917 .filter(|(_, ch)| ch.retire_at.is_none())
2918 .map(|(id, _)| id.clone())
2919 .collect()
2920 }
2921
2922 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 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
2952fn tool_result_from_dispatch(call_id: ToolCallId, result: ToolResult) -> LiveToolResult {
2957 LiveToolResult {
2958 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 #[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 assert_eq!(DEFAULT_LIVE_TOOL_TIMEOUT, Duration::from_secs(30));
3000 }
3001
3002 #[tokio::test]
3024 async fn projection_sink_is_mandatory_at_construction() {
3025 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 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 #[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 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 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 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 #[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 {
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 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 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 #[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 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 host.close_channel_with_generated_test_machine_authority(&closing)
3317 .await
3318 .unwrap();
3319
3320 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 assert_eq!(
3331 host.channel_status(&closing).await.unwrap(),
3332 LiveAdapterStatus::Closed,
3333 );
3334
3335 {
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 #[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 let status = host.channel_status(&ch).await.unwrap();
3394 assert_eq!(status, LiveAdapterStatus::Closed);
3395
3396 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[tokio::test]
3876 async fn adapter_pump_error_routes_close_through_generated_authority() {
3877 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 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 #[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 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 #[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 let _ = host.next_observation_raw(&ch1).await.unwrap();
4044
4045 {
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 #[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 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 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 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 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 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 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 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 #[tokio::test]
4251 async fn assistant_transcript_delta_routes_to_transcript_lane() {
4252 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 assert!(
4279 sink.text_deltas.lock().unwrap().is_empty(),
4280 "AssistantTranscriptDelta must not reach the text-lane sink (T6)"
4281 );
4282 }
4283
4284 #[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 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 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 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 #[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 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 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 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 #[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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 let host_call = async { host.apply_observation(&ch, &obs).await.unwrap() };
4762 let drive_clock = async {
4763 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 assert_eq!(*dispatcher.calls.lock().unwrap(), 1);
4785
4786 let errors = adapter.submitted_errors.lock().unwrap();
4788 assert_eq!(errors.len(), 1);
4789 assert_eq!(errors[0].0, "call_slow");
4790 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 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 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 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 #[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 assert_eq!(interrupts[0].1.as_deref(), Some("resp_42"));
4892 }
4893
4894 #[tokio::test]
4897 async fn transport_barge_in_emits_typed_interrupt_via_sink() {
4898 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 assert_eq!(interrupts[0].1, None);
4923 }
4924
4925 #[tokio::test]
4929 async fn transport_output_audio_degradation_emits_typed_signal_via_sink() {
4930 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 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 #[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 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 #[tokio::test]
5019 async fn duplicate_session_check_uses_reverse_map() {
5020 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 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 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 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 #[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 #[derive(Default)]
5239 struct RecordingDispatcher {
5240 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 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 #[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 #[derive(Default)]
5306 #[allow(clippy::type_complexity)]
5307 struct RecordingProjectionSink {
5308 user_transcripts: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
5309 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 #[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 assert!(matches!(
5533 LiveProjectionError::from_session_error(
5534 &sid,
5535 SessionError::NotFound { id: sid.clone() }
5536 ),
5537 LiveProjectionError::SessionNotFound(_)
5538 ));
5539
5540 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 assert!(matches!(
5551 LiveProjectionError::from_session_error(&sid, SessionError::Busy { id: sid.clone() }),
5552 LiveProjectionError::SessionBusy(_)
5553 ));
5554
5555 assert!(matches!(
5557 LiveProjectionError::from_session_error(
5558 &sid,
5559 SessionError::NotRunning { id: sid.clone() }
5560 ),
5561 LiveProjectionError::SessionNotRunning(_)
5562 ));
5563
5564 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 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 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 #[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 assert!(matches!(
5622 LiveToolDispatchError::from_session_error(
5623 &sid,
5624 SessionError::NotFound { id: sid.clone() }
5625 ),
5626 LiveToolDispatchError::SessionNotFound(_)
5627 ));
5628
5629 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 assert!(matches!(
5643 LiveToolDispatchError::from_session_error(&sid, SessionError::Busy { id: sid.clone() }),
5644 LiveToolDispatchError::SessionBusy(_)
5645 ));
5646
5647 assert!(matches!(
5649 LiveToolDispatchError::from_session_error(
5650 &sid,
5651 SessionError::NotRunning { id: sid.clone() }
5652 ),
5653 LiveToolDispatchError::SessionNotRunning(_)
5654 ));
5655
5656 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 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 #[test]
5681 fn delta_identity_requires_response_delta_and_item_ids() {
5682 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 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 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 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}