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::ToolCallId;
45use meerkat_core::ToolDispatchOutcome;
46use meerkat_core::ToolError;
47use meerkat_core::live_adapter::{
48 LiveAdapter, LiveAdapterCommand, LiveAdapterError, LiveAdapterErrorCode,
49 LiveAdapterObservation, LiveAdapterStatus, LiveInputChunk, LiveToolResult,
50};
51use meerkat_core::types::{SessionId, StopReason, ToolCall, ToolName, ToolResult, Usage};
52use meerkat_core::{
53 RealtimeTranscriptApplyOutcome, RealtimeTranscriptEvent, RealtimeUserContentApplyOutcome,
54};
55use tokio::sync::Mutex;
56
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub struct LiveChannelId(String);
69
70impl LiveChannelId {
71 #[must_use]
72 pub fn new(id: impl Into<String>) -> Self {
73 Self(id.into())
74 }
75
76 #[must_use]
82 pub fn random_uuid() -> Self {
83 Self(uuid::Uuid::new_v4().to_string())
84 }
85
86 #[must_use]
87 pub fn as_str(&self) -> &str {
88 &self.0
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct LiveRefreshQueueAcceptance {
100 channel_id: String,
101 acceptance_sequence: u64,
102}
103
104impl LiveRefreshQueueAcceptance {
105 #[must_use]
106 pub fn channel_id(&self) -> &str {
107 &self.channel_id
108 }
109
110 #[must_use]
111 pub fn acceptance_sequence(&self) -> u64 {
112 self.acceptance_sequence
113 }
114
115 fn from_host_queue_acceptance(
116 channel_id: impl Into<String>,
117 acceptance_sequence: u64,
118 ) -> Option<Self> {
119 let channel_id = channel_id.into();
120 if channel_id.is_empty() || acceptance_sequence == 0 {
121 return None;
122 }
123 Some(Self {
124 channel_id,
125 acceptance_sequence,
126 })
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
133pub enum LiveCommandAcceptanceKind {
134 SendInput,
135 CommitInput,
136 Interrupt,
137 TruncateAssistantOutput,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct LiveCommandQueueAcceptance {
148 channel_id: String,
149 kind: LiveCommandAcceptanceKind,
150 acceptance_sequence: u64,
151}
152
153impl LiveCommandQueueAcceptance {
154 #[must_use]
155 pub fn channel_id(&self) -> &str {
156 &self.channel_id
157 }
158
159 #[must_use]
160 pub fn kind(&self) -> LiveCommandAcceptanceKind {
161 self.kind
162 }
163
164 #[must_use]
165 pub fn acceptance_sequence(&self) -> u64 {
166 self.acceptance_sequence
167 }
168
169 fn from_host_queue_acceptance(
170 channel_id: impl Into<String>,
171 kind: LiveCommandAcceptanceKind,
172 acceptance_sequence: u64,
173 ) -> Option<Self> {
174 let channel_id = channel_id.into();
175 if channel_id.is_empty() || acceptance_sequence == 0 {
176 return None;
177 }
178 Some(Self {
179 channel_id,
180 kind,
181 acceptance_sequence,
182 })
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct LiveChannelCloseObservation {
193 channel_id: String,
194 close_sequence: u64,
195}
196
197impl LiveChannelCloseObservation {
198 #[must_use]
199 pub fn channel_id(&self) -> &str {
200 &self.channel_id
201 }
202
203 #[must_use]
204 pub fn close_sequence(&self) -> u64 {
205 self.close_sequence
206 }
207
208 fn from_host_close_observation(
209 channel_id: impl Into<String>,
210 close_sequence: u64,
211 ) -> Option<Self> {
212 let channel_id = channel_id.into();
213 if channel_id.is_empty() || close_sequence == 0 {
214 return None;
215 }
216 Some(Self {
217 channel_id,
218 close_sequence,
219 })
220 }
221}
222
223#[derive(Debug, Clone)]
225pub struct LiveChannelCloseCommitAuthority {
226 channel_id: String,
227 close_sequence: u64,
228 consumed: Arc<AtomicBool>,
229}
230
231impl LiveChannelCloseCommitAuthority {
232 #[must_use]
233 pub fn channel_id(&self) -> &str {
234 &self.channel_id
235 }
236
237 #[must_use]
238 pub fn close_sequence(&self) -> u64 {
239 self.close_sequence
240 }
241
242 fn consume_once(&self) -> Result<(), LiveAdapterHostError> {
243 self.consumed
244 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
245 .map(|_| ())
246 .map_err(|_| LiveAdapterHostError::CloseAuthorityAlreadyConsumed)
247 }
248
249 #[cfg_attr(not(meerkat_internal_generated_authority_bridge), allow(dead_code))]
250 fn from_generated_parts(channel_id: String, close_sequence: u64) -> Self {
251 Self {
252 channel_id,
253 close_sequence,
254 consumed: Arc::new(AtomicBool::new(false)),
255 }
256 }
257
258 #[cfg(test)]
259 fn from_generated_test_machine(
260 session_id: &SessionId,
261 channel_id: &LiveChannelId,
262 close_sequence: u64,
263 ) -> Self {
264 let mut authority = generated_test_machine_for_registered_session(session_id);
265 let channel_id_string = channel_id.as_str().to_owned();
266 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
267 &mut authority,
268 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::ResolveLiveOpenAdmission {
269 session_id: session_id.to_string(),
270 channel_id: channel_id_string.clone(),
271 llm_identity: generated_test_llm_identity(),
272 },
273 )
274 .expect("generated MeerkatMachine live-open admission");
275 let transition = meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
276 &mut authority,
277 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::RecordLiveCloseClosed {
278 session_id: session_id.to_string(),
279 channel_id: channel_id_string.clone(),
280 close_observation_sequence: close_sequence,
281 },
282 )
283 .expect("generated MeerkatMachine live-close result");
284 assert!(
285 transition.effects().iter().any(|effect| matches!(
286 effect,
287 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineEffect::LiveCloseResultResolved {
288 channel_id: effect_channel_id,
289 close_observation_sequence,
290 ..
291 } if *effect_channel_id == channel_id_string && *close_observation_sequence == close_sequence
292 )),
293 "generated live-close result effect"
294 );
295 Self::from_generated_parts(channel_id_string, close_sequence)
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct LiveChannelStatusObservation {
308 channel_id: String,
309 status: LiveAdapterStatus,
310 observation_sequence: u64,
311}
312
313impl LiveChannelStatusObservation {
314 #[must_use]
315 pub fn channel_id(&self) -> &str {
316 &self.channel_id
317 }
318
319 #[must_use]
320 pub fn status(&self) -> &LiveAdapterStatus {
321 &self.status
322 }
323
324 #[must_use]
325 pub fn observation_sequence(&self) -> u64 {
326 self.observation_sequence
327 }
328
329 fn from_host_status_observation(
330 channel_id: impl Into<String>,
331 status: LiveAdapterStatus,
332 observation_sequence: u64,
333 ) -> Option<Self> {
334 let channel_id = channel_id.into();
335 if channel_id.is_empty() || observation_sequence == 0 {
336 return None;
337 }
338 Some(Self {
339 channel_id,
340 status,
341 observation_sequence,
342 })
343 }
344}
345
346#[derive(Debug, Clone)]
348pub struct LiveChannelStatusCommitAuthority {
349 channel_id: String,
350 status_observation_sequence: u64,
351 consumed: Arc<AtomicBool>,
352}
353
354impl LiveChannelStatusCommitAuthority {
355 #[must_use]
356 pub fn channel_id(&self) -> &str {
357 &self.channel_id
358 }
359
360 #[must_use]
361 pub fn status_observation_sequence(&self) -> u64 {
362 self.status_observation_sequence
363 }
364
365 fn consume_once(&self) -> Result<(), LiveAdapterHostError> {
366 self.consumed
367 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
368 .map(|_| ())
369 .map_err(|_| LiveAdapterHostError::StatusAuthorityAlreadyConsumed)
370 }
371
372 #[cfg_attr(not(meerkat_internal_generated_authority_bridge), allow(dead_code))]
373 fn from_generated_parts(channel_id: String, status_observation_sequence: u64) -> Self {
374 Self {
375 channel_id,
376 status_observation_sequence,
377 consumed: Arc::new(AtomicBool::new(false)),
378 }
379 }
380}
381
382impl std::fmt::Display for LiveChannelId {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 f.write_str(&self.0)
385 }
386}
387
388#[derive(Debug, Clone, PartialEq)]
398#[non_exhaustive]
399pub enum ObservationOutcome {
400 Noop,
402 StatusUpdated(LiveAdapterStatus),
404 TranscriptAppended,
406 TranscriptTruncated,
408 ToolCallDispatched {
410 provider_call_id: String,
411 tool_name: String,
412 },
413 ToolCallSkipped {
415 provider_call_id: String,
416 tool_name: String,
417 reason: ToolDispatchSkipReason,
418 },
419 ToolCallTimedOut {
425 provider_call_id: String,
426 tool_name: String,
427 timeout: std::time::Duration,
428 },
429 InterruptSignalled,
431 Terminal { code: LiveAdapterErrorCode },
433 CommandRejected {
440 code: LiveAdapterErrorCode,
441 message: String,
442 },
443 UserContentCommitted { observation: LiveAdapterObservation },
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
452#[non_exhaustive]
453pub enum ToolDispatchSkipReason {
454 NoDispatcher,
455 InvalidArguments,
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475pub struct LiveToolDispatchTimeout {
476 timeout: Duration,
477}
478
479impl LiveToolDispatchTimeout {
480 #[must_use]
481 pub fn new(timeout: Duration) -> Self {
482 Self { timeout }
483 }
484
485 #[must_use]
486 pub fn timeout(&self) -> Duration {
487 self.timeout
488 }
489}
490
491impl std::fmt::Display for LiveToolDispatchTimeout {
492 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493 write!(f, "tool dispatch timeout after {:?}", self.timeout)
494 }
495}
496
497#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
523pub struct LiveTranscriptIdentity<'a> {
524 pub provider_item_id: Option<&'a str>,
525 pub previous_item_id: Option<&'a str>,
526 pub content_index: Option<u32>,
527 pub response_id: Option<&'a str>,
528 pub delta_id: Option<&'a str>,
529}
530
531impl<'a> LiveTranscriptIdentity<'a> {
532 pub fn user(
535 provider_item_id: Option<&'a str>,
536 previous_item_id: Option<&'a str>,
537 content_index: Option<u32>,
538 ) -> Self {
539 Self {
540 provider_item_id,
541 previous_item_id,
542 content_index,
543 response_id: None,
544 delta_id: None,
545 }
546 }
547
548 pub fn assistant_delta(
550 provider_item_id: Option<&'a str>,
551 previous_item_id: Option<&'a str>,
552 content_index: Option<u32>,
553 response_id: Option<&'a str>,
554 delta_id: Option<&'a str>,
555 ) -> Self {
556 Self {
557 provider_item_id,
558 previous_item_id,
559 content_index,
560 response_id,
561 delta_id,
562 }
563 }
564
565 pub fn assistant_final(
568 provider_item_id: &'a str,
569 previous_item_id: Option<&'a str>,
570 content_index: Option<u32>,
571 response_id: Option<&'a str>,
572 ) -> Self {
573 Self {
574 provider_item_id: Some(provider_item_id),
575 previous_item_id,
576 content_index,
577 response_id,
578 delta_id: None,
579 }
580 }
581
582 pub fn require_delta_identity(&self) -> Result<DeltaIdentity<'a>, LiveTranscriptIdentityError> {
593 let response_id = self
594 .response_id
595 .ok_or(LiveTranscriptIdentityError::MissingResponseId)?;
596 let delta_id = self
597 .delta_id
598 .ok_or(LiveTranscriptIdentityError::MissingDeltaId)?;
599 let item_id = self
600 .provider_item_id
601 .ok_or(LiveTranscriptIdentityError::MissingItemId)?;
602 Ok(DeltaIdentity {
603 response_id,
604 delta_id,
605 item_id,
606 previous_item_id: self.previous_item_id,
607 content_index: self.content_index,
608 })
609 }
610}
611
612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
618pub struct DeltaIdentity<'a> {
619 pub response_id: &'a str,
620 pub delta_id: &'a str,
621 pub item_id: &'a str,
622 pub previous_item_id: Option<&'a str>,
623 pub content_index: Option<u32>,
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
632#[non_exhaustive]
633pub enum LiveTranscriptIdentityError {
634 #[error("transcript delta is missing a required response_id")]
635 MissingResponseId,
636 #[error("transcript delta is missing a required delta_id")]
637 MissingDeltaId,
638 #[error("transcript delta is missing a required item_id")]
639 MissingItemId,
640}
641
642#[async_trait::async_trait]
643pub trait LiveProjectionSink: Send + Sync {
644 async fn append_user_transcript(
646 &self,
647 session_id: &SessionId,
648 text: &str,
649 identity: LiveTranscriptIdentity<'_>,
650 ) -> Result<(), LiveProjectionError>;
651
652 async fn append_assistant_text_delta(
660 &self,
661 session_id: &SessionId,
662 delta: &str,
663 identity: LiveTranscriptIdentity<'_>,
664 ) -> Result<(), LiveProjectionError>;
665
666 async fn append_assistant_transcript_delta(
675 &self,
676 session_id: &SessionId,
677 delta: &str,
678 identity: LiveTranscriptIdentity<'_>,
679 ) -> Result<(), LiveProjectionError>;
680
681 async fn append_assistant_text_final(
689 &self,
690 session_id: &SessionId,
691 text: &str,
692 identity: LiveTranscriptIdentity<'_>,
693 stop_reason: StopReason,
694 usage: Usage,
695 response_id: Option<&str>,
696 ) -> Result<(), LiveProjectionError>;
697
698 async fn append_assistant_transcript_final(
707 &self,
708 session_id: &SessionId,
709 text: &str,
710 identity: LiveTranscriptIdentity<'_>,
711 stop_reason: StopReason,
712 usage: Usage,
713 response_id: Option<&str>,
714 ) -> Result<(), LiveProjectionError>;
715
716 async fn truncate_assistant_transcript(
724 &self,
725 session_id: &SessionId,
726 provider_item_id: Option<&str>,
727 previous_item_id: Option<&str>,
728 content_index: Option<u32>,
729 response_id: Option<&str>,
730 text: Option<&str>,
731 ) -> Result<(), LiveProjectionError>;
732
733 async fn signal_turn_interrupt(
745 &self,
746 session_id: &SessionId,
747 response_id: Option<&str>,
748 ) -> Result<(), LiveProjectionError>;
749
750 async fn signal_output_audio_degraded(
759 &self,
760 session_id: &SessionId,
761 dropped: u64,
762 ) -> Result<(), LiveProjectionError>;
763
764 async fn signal_turn_completed(
772 &self,
773 session_id: &SessionId,
774 stop_reason: StopReason,
775 usage: Usage,
776 response_id: Option<&str>,
777 ) -> Result<(), LiveProjectionError>;
778
779 async fn signal_terminal_error(
781 &self,
782 session_id: &SessionId,
783 code: LiveAdapterErrorCode,
784 message: &str,
785 ) -> Result<(), LiveProjectionError>;
786
787 async fn append_realtime_transcript(
802 &self,
803 session_id: &SessionId,
804 event: &RealtimeTranscriptEvent,
805 ) -> Result<RealtimeTranscriptApplyOutcome, LiveProjectionError>;
806}
807
808#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
818#[non_exhaustive]
819pub enum LiveProjectionError {
820 #[error("session {0} not found")]
821 SessionNotFound(SessionId),
822 #[error("projection rejected: {0}")]
825 Rejected(String),
826 #[error("session busy: {0}")]
829 SessionBusy(SessionId),
830 #[error("session not running: {0}")]
833 SessionNotRunning(SessionId),
834 #[error("session capability disabled ({code}): {message}")]
837 CapabilityDisabled { code: &'static str, message: String },
838 #[error("session error [{code}]: {message}")]
842 Session { code: &'static str, message: String },
843}
844
845impl LiveProjectionError {
846 #[must_use]
856 pub fn from_session_error(session_id: &SessionId, err: meerkat_core::SessionError) -> Self {
857 use meerkat_core::SessionError;
858 let code = err.code();
862 let message = err.to_string();
863 match err {
864 SessionError::NotFound { .. } => Self::SessionNotFound(session_id.clone()),
865 SessionError::Unsupported(reason) => Self::Rejected(reason),
866 SessionError::Busy { id } => Self::SessionBusy(id),
867 SessionError::NotRunning { id } => Self::SessionNotRunning(id),
868 SessionError::PersistenceDisabled | SessionError::CompactionDisabled => {
869 Self::CapabilityDisabled { code, message }
870 }
871 SessionError::Store(_)
879 | SessionError::Agent(_)
880 | SessionError::DurableTailHeldForRecovery { .. }
881 | SessionError::DurableEvidenceQuarantined { .. }
882 | SessionError::FailedWithData { .. } => Self::Session { code, message },
883 }
884 }
885}
886
887#[doc(hidden)]
902#[derive(Debug, Default)]
903pub struct NoOpProjectionSink;
904
905#[async_trait::async_trait]
906impl LiveProjectionSink for NoOpProjectionSink {
907 async fn append_user_transcript(
908 &self,
909 _session_id: &SessionId,
910 _text: &str,
911 _identity: LiveTranscriptIdentity<'_>,
912 ) -> Result<(), LiveProjectionError> {
913 Ok(())
914 }
915
916 async fn append_assistant_text_delta(
917 &self,
918 _session_id: &SessionId,
919 _delta: &str,
920 _identity: LiveTranscriptIdentity<'_>,
921 ) -> Result<(), LiveProjectionError> {
922 Ok(())
923 }
924
925 async fn append_assistant_transcript_delta(
926 &self,
927 _session_id: &SessionId,
928 _delta: &str,
929 _identity: LiveTranscriptIdentity<'_>,
930 ) -> Result<(), LiveProjectionError> {
931 Ok(())
932 }
933
934 async fn append_assistant_text_final(
935 &self,
936 _session_id: &SessionId,
937 _text: &str,
938 _identity: LiveTranscriptIdentity<'_>,
939 _stop_reason: StopReason,
940 _usage: Usage,
941 _response_id: Option<&str>,
942 ) -> Result<(), LiveProjectionError> {
943 Ok(())
944 }
945
946 async fn append_assistant_transcript_final(
947 &self,
948 _session_id: &SessionId,
949 _text: &str,
950 _identity: LiveTranscriptIdentity<'_>,
951 _stop_reason: StopReason,
952 _usage: Usage,
953 _response_id: Option<&str>,
954 ) -> Result<(), LiveProjectionError> {
955 Ok(())
956 }
957
958 async fn truncate_assistant_transcript(
959 &self,
960 _session_id: &SessionId,
961 _provider_item_id: Option<&str>,
962 _previous_item_id: Option<&str>,
963 _content_index: Option<u32>,
964 _response_id: Option<&str>,
965 _text: Option<&str>,
966 ) -> Result<(), LiveProjectionError> {
967 Ok(())
968 }
969
970 async fn signal_turn_interrupt(
971 &self,
972 _session_id: &SessionId,
973 _response_id: Option<&str>,
974 ) -> Result<(), LiveProjectionError> {
975 Ok(())
976 }
977
978 async fn signal_output_audio_degraded(
979 &self,
980 _session_id: &SessionId,
981 _dropped: u64,
982 ) -> Result<(), LiveProjectionError> {
983 Ok(())
984 }
985
986 async fn signal_turn_completed(
987 &self,
988 _session_id: &SessionId,
989 _stop_reason: StopReason,
990 _usage: Usage,
991 _response_id: Option<&str>,
992 ) -> Result<(), LiveProjectionError> {
993 Ok(())
994 }
995
996 async fn signal_terminal_error(
997 &self,
998 _session_id: &SessionId,
999 _code: LiveAdapterErrorCode,
1000 _message: &str,
1001 ) -> Result<(), LiveProjectionError> {
1002 Ok(())
1003 }
1004
1005 async fn append_realtime_transcript(
1011 &self,
1012 _session_id: &SessionId,
1013 _event: &RealtimeTranscriptEvent,
1014 ) -> Result<RealtimeTranscriptApplyOutcome, LiveProjectionError> {
1015 Ok(RealtimeTranscriptApplyOutcome::default())
1016 }
1017}
1018
1019const CLOSED_CHANNEL_TTL: std::time::Duration = std::time::Duration::from_secs(60);
1027
1028struct ChannelState {
1030 session_id: SessionId,
1031 status: LiveAdapterStatus,
1035 status_observation_sequence: u64,
1039 snapshot_version: u64,
1040 refresh_acceptance_sequence: u64,
1041 command_acceptance_sequence: u64,
1042 close_observation_sequence: u64,
1043 adapter: Option<Arc<dyn LiveAdapter>>,
1044 physical_close_confirmed: bool,
1049 retire_at: Option<std::time::Instant>,
1056 pending_synthetic_obs: Option<LiveAdapterObservation>,
1073}
1074
1075#[derive(Debug, Clone)]
1081pub struct LiveChannelOpenAuthority {
1082 session_id: SessionId,
1083 channel_id: LiveChannelId,
1084 sequence: u64,
1085 consumed: Arc<AtomicBool>,
1086}
1087
1088#[cfg(test)]
1089fn generated_test_llm_identity()
1090-> meerkat_machine_schema::catalog::dsl::meerkat_machine::SessionLlmIdentity {
1091 meerkat_machine_schema::catalog::dsl::meerkat_machine::SessionLlmIdentity {
1092 model: "gpt-realtime-2".to_string(),
1093 provider: meerkat_machine_schema::catalog::dsl::meerkat_machine::Provider::OpenAI,
1094 self_hosted_server_id: None,
1095 provider_params_repr: None,
1096 auth_binding: None,
1097 }
1098}
1099
1100#[cfg(test)]
1101fn generated_test_machine_for_registered_session(
1102 session_id: &SessionId,
1103) -> meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineAuthority {
1104 use meerkat_machine_schema::catalog::dsl::meerkat_machine::{
1105 MeerkatMachineAuthority, MeerkatMachineInput, MeerkatMachineMutator, MeerkatMachineSignal,
1106 SessionId as DslSessionId,
1107 };
1108
1109 let mut authority = MeerkatMachineAuthority::new();
1110 authority
1111 .apply_signal(MeerkatMachineSignal::Initialize)
1112 .expect("initialize generated MeerkatMachine authority");
1113 MeerkatMachineMutator::apply(
1114 &mut authority,
1115 MeerkatMachineInput::RegisterSession {
1116 session_id: DslSessionId(session_id.to_string()),
1117 },
1118 )
1119 .expect("register session in generated MeerkatMachine authority");
1120 authority
1121}
1122
1123#[cfg(test)]
1124fn generated_test_live_channel_status(
1125 status: &LiveAdapterStatus,
1126) -> (
1127 meerkat_machine_schema::catalog::dsl::meerkat_machine::LiveChannelPublicStatus,
1128 Option<meerkat_machine_schema::catalog::dsl::meerkat_machine::LiveChannelDegradationReason>,
1129 Option<String>,
1130) {
1131 use meerkat_core::live_adapter::LiveDegradationReason;
1132 use meerkat_machine_schema::catalog::dsl::meerkat_machine::{
1133 LiveChannelDegradationReason as DslReason, LiveChannelPublicStatus as DslStatus,
1134 };
1135
1136 match status {
1137 LiveAdapterStatus::Idle => (DslStatus::Idle, None, None),
1138 LiveAdapterStatus::Opening => (DslStatus::Opening, None, None),
1139 LiveAdapterStatus::Ready => (DslStatus::Ready, None, None),
1140 LiveAdapterStatus::Closing => (DslStatus::Closing, None, None),
1141 LiveAdapterStatus::Closed => (DslStatus::Closed, None, None),
1142 LiveAdapterStatus::Degraded { reason } => match reason {
1143 LiveDegradationReason::RateLimited => {
1144 (DslStatus::Degraded, Some(DslReason::RateLimited), None)
1145 }
1146 LiveDegradationReason::ProviderThrottled => (
1147 DslStatus::Degraded,
1148 Some(DslReason::ProviderThrottled),
1149 None,
1150 ),
1151 LiveDegradationReason::NetworkUnstable => {
1152 (DslStatus::Degraded, Some(DslReason::NetworkUnstable), None)
1153 }
1154 LiveDegradationReason::Other { detail } => (
1155 DslStatus::Degraded,
1156 Some(DslReason::Other),
1157 Some(detail.clone().into_owned()),
1158 ),
1159 other => (
1160 DslStatus::Degraded,
1161 Some(DslReason::Unknown),
1162 Some(format!("{other:?}")),
1163 ),
1164 },
1165 other => (
1166 DslStatus::Degraded,
1167 Some(DslReason::Unknown),
1168 Some(format!("{other:?}")),
1169 ),
1170 }
1171}
1172
1173impl LiveChannelOpenAuthority {
1174 #[must_use]
1175 pub fn session_id(&self) -> &SessionId {
1176 &self.session_id
1177 }
1178
1179 #[must_use]
1180 pub fn channel_id(&self) -> &LiveChannelId {
1181 &self.channel_id
1182 }
1183
1184 #[must_use]
1185 pub fn sequence(&self) -> u64 {
1186 self.sequence
1187 }
1188
1189 fn consume_once(&self) -> Result<(), LiveAdapterHostError> {
1190 self.consumed
1191 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1192 .map(|_| ())
1193 .map_err(|_| LiveAdapterHostError::OpenAuthorityAlreadyConsumed)
1194 }
1195
1196 #[cfg_attr(not(meerkat_internal_generated_authority_bridge), allow(dead_code))]
1197 fn from_generated_parts(
1198 session_id: SessionId,
1199 channel_id: LiveChannelId,
1200 sequence: u64,
1201 ) -> Self {
1202 Self {
1203 session_id,
1204 channel_id,
1205 sequence,
1206 consumed: Arc::new(AtomicBool::new(false)),
1207 }
1208 }
1209
1210 #[cfg(test)]
1211 fn from_generated_test_machine(session_id: SessionId, channel_id: LiveChannelId) -> Self {
1212 let mut authority = generated_test_machine_for_registered_session(&session_id);
1213 let channel_id_string = channel_id.as_str().to_owned();
1214 let transition = meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
1215 &mut authority,
1216 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::ResolveLiveOpenAdmission {
1217 session_id: session_id.to_string(),
1218 channel_id: channel_id_string.clone(),
1219 llm_identity: generated_test_llm_identity(),
1220 },
1221 )
1222 .expect("generated MeerkatMachine live-open admission");
1223 let sequence = transition
1224 .effects()
1225 .iter()
1226 .find_map(|effect| match effect {
1227 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineEffect::LiveOpenAdmissionResolved {
1228 channel_id: effect_channel_id,
1229 admitted: true,
1230 sequence,
1231 ..
1232 } if *effect_channel_id == channel_id_string => Some(*sequence),
1233 _ => None,
1234 })
1235 .expect("generated live-open admission effect");
1236 Self::from_generated_parts(session_id, channel_id, sequence)
1237 }
1238}
1239
1240#[cfg(meerkat_internal_generated_authority_bridge)]
1241#[allow(improper_ctypes_definitions, unsafe_code)]
1242unsafe extern "Rust" {
1243 #[link_name = concat!(
1244 "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_live_open_admission_",
1245 env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1246 )]
1247 fn runtime_live_open_admission_generated_authority_bridge_token_is_valid(
1248 token: &(dyn std::any::Any + Send + Sync),
1249 ) -> bool;
1250
1251 #[link_name = concat!(
1252 "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_live_close_result_",
1253 env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1254 )]
1255 fn runtime_live_close_result_generated_authority_bridge_token_is_valid(
1256 token: &(dyn std::any::Any + Send + Sync),
1257 ) -> bool;
1258
1259 #[link_name = concat!(
1260 "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_live_channel_status_result_",
1261 env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1262 )]
1263 fn runtime_live_channel_status_result_generated_authority_bridge_token_is_valid(
1264 token: &(dyn std::any::Any + Send + Sync),
1265 ) -> bool;
1266}
1267
1268#[cfg(meerkat_internal_generated_authority_bridge)]
1269#[doc(hidden)]
1270#[allow(improper_ctypes_definitions, unsafe_code)]
1271#[unsafe(export_name = concat!(
1272 "__meerkat_live_runtime_generated_live_channel_open_authority_build_v1_",
1273 env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1274))]
1275pub(crate) extern "Rust" fn runtime_generated_live_channel_open_authority_build(
1276 token: &'static (dyn std::any::Any + Send + Sync),
1277 session_id: SessionId,
1278 channel_id: LiveChannelId,
1279 sequence: u64,
1280) -> Result<LiveChannelOpenAuthority, String> {
1281 #[allow(unsafe_code)]
1282 let valid =
1283 unsafe { runtime_live_open_admission_generated_authority_bridge_token_is_valid(token) };
1284 if !valid {
1285 return Err(
1286 "live channel open authority requires the generated runtime admission bridge token"
1287 .into(),
1288 );
1289 }
1290 Ok(LiveChannelOpenAuthority::from_generated_parts(
1291 session_id, channel_id, sequence,
1292 ))
1293}
1294
1295#[cfg(meerkat_internal_generated_authority_bridge)]
1296#[doc(hidden)]
1297#[allow(improper_ctypes_definitions, unsafe_code)]
1298#[unsafe(export_name = concat!(
1299 "__meerkat_live_runtime_generated_live_channel_close_commit_authority_build_v1_",
1300 env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1301))]
1302pub(crate) extern "Rust" fn runtime_generated_live_channel_close_commit_authority_build(
1303 token: &'static (dyn std::any::Any + Send + Sync),
1304 channel_id: String,
1305 close_sequence: u64,
1306) -> Result<LiveChannelCloseCommitAuthority, String> {
1307 #[allow(unsafe_code)]
1308 let valid =
1309 unsafe { runtime_live_close_result_generated_authority_bridge_token_is_valid(token) };
1310 if !valid {
1311 return Err(
1312 "live channel close commit authority requires the generated runtime close bridge token"
1313 .into(),
1314 );
1315 }
1316 Ok(LiveChannelCloseCommitAuthority::from_generated_parts(
1317 channel_id,
1318 close_sequence,
1319 ))
1320}
1321
1322#[cfg(meerkat_internal_generated_authority_bridge)]
1323#[doc(hidden)]
1324#[allow(improper_ctypes_definitions, unsafe_code)]
1325#[unsafe(export_name = concat!(
1326 "__meerkat_live_runtime_generated_live_channel_status_commit_authority_build_v1_",
1327 env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
1328))]
1329pub(crate) extern "Rust" fn runtime_generated_live_channel_status_commit_authority_build(
1330 token: &'static (dyn std::any::Any + Send + Sync),
1331 channel_id: String,
1332 status_observation_sequence: u64,
1333) -> Result<LiveChannelStatusCommitAuthority, String> {
1334 #[allow(unsafe_code)]
1335 let valid = unsafe {
1336 runtime_live_channel_status_result_generated_authority_bridge_token_is_valid(token)
1337 };
1338 if !valid {
1339 return Err(
1340 "live channel status commit authority requires the generated runtime status bridge token"
1341 .into(),
1342 );
1343 }
1344 Ok(LiveChannelStatusCommitAuthority::from_generated_parts(
1345 channel_id,
1346 status_observation_sequence,
1347 ))
1348}
1349
1350#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1352#[non_exhaustive]
1353pub enum LiveAdapterHostError {
1354 #[error("channel {0} not found")]
1355 ChannelNotFound(LiveChannelId),
1356 #[error("session {0} not found")]
1357 SessionNotFound(SessionId),
1358 #[error("channel {0} is not ready (status: {1:?})")]
1359 ChannelNotReady(LiveChannelId, LiveAdapterStatus),
1360 #[error("session {0} already has an active channel")]
1361 SessionAlreadyBound(SessionId),
1362 #[error("live channel open lacks generated admission authority")]
1363 OpenNotAuthorized,
1364 #[error("live channel open authority was already consumed")]
1365 OpenAuthorityAlreadyConsumed,
1366 #[error("live channel close lacks generated commit authority")]
1367 CloseNotAuthorized,
1368 #[error("live channel close authority was already consumed")]
1369 CloseAuthorityAlreadyConsumed,
1370 #[error("live channel status lacks generated commit authority")]
1371 StatusNotAuthorized,
1372 #[error("live channel status authority was already consumed")]
1373 StatusAuthorityAlreadyConsumed,
1374 #[error("no adapter attached to channel {0}")]
1375 NoAdapter(LiveChannelId),
1376 #[error("unsupported host command: {0}")]
1377 UnsupportedCommand(&'static str),
1378 #[error(transparent)]
1380 AdapterError(#[from] LiveAdapterError),
1381 #[error("projection sink error: {0}")]
1382 ProjectionError(#[from] LiveProjectionError),
1383}
1384
1385impl LiveAdapterHostError {
1386 #[must_use]
1395 pub fn reason_code(&self) -> &'static str {
1396 match self {
1397 Self::ChannelNotFound(_) => "channel_not_found",
1398 Self::SessionNotFound(_) => "session_not_found",
1399 Self::ChannelNotReady(..) => "channel_not_ready",
1400 Self::SessionAlreadyBound(_) => "session_already_bound",
1401 Self::OpenNotAuthorized => "open_not_authorized",
1402 Self::OpenAuthorityAlreadyConsumed => "open_authority_already_consumed",
1403 Self::CloseNotAuthorized => "close_not_authorized",
1404 Self::CloseAuthorityAlreadyConsumed => "close_authority_already_consumed",
1405 Self::StatusNotAuthorized => "status_not_authorized",
1406 Self::StatusAuthorityAlreadyConsumed => "status_authority_already_consumed",
1407 Self::NoAdapter(_) => "no_adapter",
1408 Self::UnsupportedCommand(_) => "unsupported_command",
1409 Self::AdapterError(_) => "adapter_error",
1410 Self::ProjectionError(_) => "projection_error",
1411 }
1412 }
1413}
1414
1415#[derive(Debug, Clone, PartialEq)]
1421#[non_exhaustive]
1422pub enum ObservationRouting {
1423 AppendTranscript,
1424 AppendRealtimeTranscript,
1430 DispatchToolCall {
1431 provider_call_id: String,
1432 tool_name: String,
1433 },
1434 SignalInterrupt,
1435 UpdateStatus(LiveAdapterStatus),
1436 TerminalError,
1437 CommandRejection,
1441 Noop,
1442}
1443
1444#[async_trait::async_trait]
1451pub trait LiveToolDispatcher: Send + Sync {
1452 async fn dispatch_live_tool_call(
1453 &self,
1454 session_id: &SessionId,
1455 call: ToolCall,
1456 ) -> Result<ToolDispatchOutcome, LiveToolDispatchError>;
1457}
1458
1459#[derive(Debug, Clone, thiserror::Error)]
1471#[non_exhaustive]
1472pub enum LiveToolDispatchError {
1473 #[error(transparent)]
1474 Tool(#[from] ToolError),
1475 #[error("live tool dispatch target session {0} not found")]
1477 SessionNotFound(SessionId),
1478 #[error("live tool dispatch target session {0} is busy")]
1481 SessionBusy(SessionId),
1482 #[error("live tool dispatch target session {0} is not running")]
1485 SessionNotRunning(SessionId),
1486 #[error("live tool dispatch capability disabled ({code}): {message}")]
1489 CapabilityDisabled { code: &'static str, message: String },
1490 #[error("live tool dispatch rejected: {0}")]
1493 Rejected(String),
1494 #[error("live tool dispatch session error [{code}]: {message}")]
1498 Session { code: &'static str, message: String },
1499}
1500
1501impl LiveToolDispatchError {
1502 #[must_use]
1514 pub fn from_session_error(session_id: &SessionId, err: meerkat_core::SessionError) -> Self {
1515 use meerkat_core::SessionError;
1516 let code = err.code();
1520 let message = err.to_string();
1521 match err {
1522 SessionError::NotFound { .. } => Self::SessionNotFound(session_id.clone()),
1523 SessionError::Unsupported(reason) => Self::Rejected(reason),
1524 SessionError::Busy { id } => Self::SessionBusy(id),
1525 SessionError::NotRunning { id } => Self::SessionNotRunning(id),
1526 SessionError::PersistenceDisabled | SessionError::CompactionDisabled => {
1527 Self::CapabilityDisabled { code, message }
1528 }
1529 SessionError::Store(_)
1537 | SessionError::Agent(_)
1538 | SessionError::DurableTailHeldForRecovery { .. }
1539 | SessionError::DurableEvidenceQuarantined { .. }
1540 | SessionError::FailedWithData { .. } => Self::Session { code, message },
1541 }
1542 }
1543}
1544
1545pub struct LiveAdapterHost {
1554 inner: Mutex<HostInner>,
1555 projection_sink: Arc<dyn LiveProjectionSink>,
1570 tool_dispatcher: std::sync::Mutex<Option<Arc<dyn LiveToolDispatcher>>>,
1578 tool_timeout: Duration,
1592}
1593
1594pub const DEFAULT_LIVE_TOOL_TIMEOUT: Duration = Duration::from_secs(30);
1600
1601struct HostInner {
1602 channels: IndexMap<LiveChannelId, ChannelState>,
1605 by_session: HashMap<SessionId, LiveChannelId>,
1608}
1609
1610impl LiveAdapterHost {
1611 #[must_use]
1619 pub fn new(projection_sink: Arc<dyn LiveProjectionSink>) -> Self {
1620 Self {
1621 inner: Mutex::new(HostInner {
1622 channels: IndexMap::new(),
1623 by_session: HashMap::new(),
1624 }),
1625 projection_sink,
1626 tool_dispatcher: std::sync::Mutex::new(None),
1627 tool_timeout: DEFAULT_LIVE_TOOL_TIMEOUT,
1628 }
1629 }
1630
1631 #[must_use]
1645 pub fn with_tool_timeout(mut self, timeout: Duration) -> Self {
1646 self.tool_timeout = timeout;
1647 self
1648 }
1649
1650 #[must_use]
1652 pub fn tool_timeout(&self) -> Duration {
1653 self.tool_timeout
1654 }
1655
1656 #[must_use]
1658 pub fn with_live_tool_dispatcher(self, dispatcher: Arc<dyn LiveToolDispatcher>) -> Self {
1659 self.set_live_tool_dispatcher(dispatcher);
1660 self
1661 }
1662
1663 pub fn set_live_tool_dispatcher(&self, dispatcher: Arc<dyn LiveToolDispatcher>) {
1673 if let Ok(mut slot) = self.tool_dispatcher.lock() {
1677 *slot = Some(dispatcher);
1678 }
1679 }
1685
1686 fn load_dispatcher(&self) -> Option<Arc<dyn LiveToolDispatcher>> {
1692 self.tool_dispatcher
1693 .lock()
1694 .ok()
1695 .and_then(|slot| slot.as_ref().map(Arc::clone))
1696 }
1697
1698 pub async fn open_channel_with_authority(
1699 &self,
1700 authority: &LiveChannelOpenAuthority,
1701 ) -> Result<LiveChannelId, LiveAdapterHostError> {
1702 authority.consume_once()?;
1703 self.open_channel_after_generated_authority(
1704 authority.session_id().clone(),
1705 authority.channel_id().clone(),
1706 )
1707 .await
1708 }
1709
1710 #[cfg(test)]
1711 pub(crate) async fn open_channel_with_generated_test_machine_authority(
1712 &self,
1713 session_id: SessionId,
1714 ) -> Result<LiveChannelId, LiveAdapterHostError> {
1715 let channel_id = LiveChannelId::random_uuid();
1716 let authority =
1717 LiveChannelOpenAuthority::from_generated_test_machine(session_id, channel_id);
1718 self.open_channel_with_authority(&authority).await
1719 }
1720
1721 async fn open_channel_after_generated_authority(
1722 &self,
1723 session_id: SessionId,
1724 channel_id: LiveChannelId,
1725 ) -> Result<LiveChannelId, LiveAdapterHostError> {
1726 let mut inner = self.inner.lock().await;
1727 Self::reap_retired_locked(&mut inner);
1728
1729 if let Some(existing) = inner.by_session.get(&session_id).cloned()
1734 && let Some(channel) = inner.channels.get(&existing)
1735 && channel.retire_at.is_none()
1736 {
1737 return Err(LiveAdapterHostError::SessionAlreadyBound(session_id));
1738 }
1739
1740 inner.channels.insert(
1741 channel_id.clone(),
1742 ChannelState {
1743 session_id: session_id.clone(),
1744 status: LiveAdapterStatus::Opening,
1745 status_observation_sequence: 0,
1746 snapshot_version: 0,
1747 refresh_acceptance_sequence: 0,
1748 command_acceptance_sequence: 0,
1749 close_observation_sequence: 0,
1750 adapter: None,
1751 physical_close_confirmed: true,
1752 retire_at: None,
1753 pending_synthetic_obs: None,
1754 },
1755 );
1756 inner.by_session.insert(session_id, channel_id.clone());
1757
1758 Ok(channel_id)
1759 }
1760
1761 pub async fn attach_adapter(
1767 &self,
1768 channel_id: &LiveChannelId,
1769 adapter: Arc<dyn LiveAdapter>,
1770 ) -> Result<(), LiveAdapterHostError> {
1771 let mut inner = self.inner.lock().await;
1772 let channel = inner
1773 .channels
1774 .get_mut(channel_id)
1775 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
1776 if channel.retire_at.is_some() {
1777 return Err(LiveAdapterHostError::ChannelNotFound(channel_id.clone()));
1778 }
1779 channel.adapter = Some(adapter);
1780 channel.physical_close_confirmed = false;
1781 Ok(())
1784 }
1785
1786 fn command_acceptance_kind(
1787 command: &LiveAdapterCommand,
1788 ) -> Result<LiveCommandAcceptanceKind, LiveAdapterHostError> {
1789 match command {
1790 LiveAdapterCommand::SendInput { .. } => Ok(LiveCommandAcceptanceKind::SendInput),
1791 LiveAdapterCommand::CommitInput { .. } => Ok(LiveCommandAcceptanceKind::CommitInput),
1792 LiveAdapterCommand::Interrupt => Ok(LiveCommandAcceptanceKind::Interrupt),
1793 LiveAdapterCommand::TruncateAssistantOutput { .. } => {
1794 Ok(LiveCommandAcceptanceKind::TruncateAssistantOutput)
1795 }
1796 LiveAdapterCommand::Refresh { .. } => Err(LiveAdapterHostError::UnsupportedCommand(
1797 "refresh commands must use LiveAdapterHost::enqueue_refresh",
1798 )),
1799 _ => Err(LiveAdapterHostError::UnsupportedCommand(
1800 "live command has no public queue-acceptance authority",
1801 )),
1802 }
1803 }
1804
1805 async fn record_command_queue_acceptance(
1806 &self,
1807 channel_id: &LiveChannelId,
1808 kind: LiveCommandAcceptanceKind,
1809 ) -> Result<LiveCommandQueueAcceptance, LiveAdapterHostError> {
1810 let mut inner = self.inner.lock().await;
1811 let channel = inner
1812 .channels
1813 .get_mut(channel_id)
1814 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
1815 channel.command_acceptance_sequence = channel.command_acceptance_sequence.saturating_add(1);
1816 LiveCommandQueueAcceptance::from_host_queue_acceptance(
1817 channel_id.as_str().to_owned(),
1818 kind,
1819 channel.command_acceptance_sequence,
1820 )
1821 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
1822 }
1823
1824 pub async fn send_command(
1826 &self,
1827 channel_id: &LiveChannelId,
1828 command: LiveAdapterCommand,
1829 ) -> Result<(), LiveAdapterHostError> {
1830 if matches!(&command, LiveAdapterCommand::Refresh { .. }) {
1831 return Err(LiveAdapterHostError::UnsupportedCommand(
1832 "refresh commands must use LiveAdapterHost::enqueue_refresh",
1833 ));
1834 }
1835 let adapter = self
1836 .adapter_for(channel_id, false)
1837 .await?;
1838 adapter.send_command(command).await?;
1839 Ok(())
1840 }
1841
1842 pub async fn send_command_observed(
1845 &self,
1846 channel_id: &LiveChannelId,
1847 command: LiveAdapterCommand,
1848 ) -> Result<LiveCommandQueueAcceptance, LiveAdapterHostError> {
1849 if matches!(&command, LiveAdapterCommand::Refresh { .. }) {
1850 return Err(LiveAdapterHostError::UnsupportedCommand(
1851 "refresh commands must use LiveAdapterHost::enqueue_refresh",
1852 ));
1853 }
1854 let acceptance_kind = Self::command_acceptance_kind(&command)?;
1855 let adapter = self
1856 .adapter_for(channel_id, false)
1857 .await?;
1858 adapter.send_command(command).await?;
1859 self.record_command_queue_acceptance(channel_id, acceptance_kind)
1860 .await
1861 }
1862
1863 pub async fn enqueue_refresh(
1869 &self,
1870 channel_id: &LiveChannelId,
1871 snapshot: meerkat_core::live_adapter::LiveProjectionSnapshot,
1872 ) -> Result<LiveRefreshQueueAcceptance, LiveAdapterHostError> {
1873 let adapter = self
1874 .adapter_for(channel_id, false)
1875 .await?;
1876 adapter
1877 .send_command(LiveAdapterCommand::Refresh { snapshot })
1878 .await?;
1879
1880 let mut inner = self.inner.lock().await;
1881 let channel = inner
1882 .channels
1883 .get_mut(channel_id)
1884 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
1885 channel.refresh_acceptance_sequence = channel.refresh_acceptance_sequence.saturating_add(1);
1886 LiveRefreshQueueAcceptance::from_host_queue_acceptance(
1887 channel_id.as_str().to_owned(),
1888 channel.refresh_acceptance_sequence,
1889 )
1890 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
1891 }
1892
1893 pub async fn send_input(
1899 &self,
1900 channel_id: &LiveChannelId,
1901 chunk: LiveInputChunk,
1902 ) -> Result<(), LiveAdapterHostError> {
1903 let adapter = self
1904 .adapter_for(channel_id, true)
1905 .await?;
1906 adapter
1907 .send_command(LiveAdapterCommand::SendInput { chunk })
1908 .await?;
1909 Ok(())
1910 }
1911
1912 pub async fn send_input_observed(
1917 &self,
1918 channel_id: &LiveChannelId,
1919 chunk: LiveInputChunk,
1920 ) -> Result<LiveCommandQueueAcceptance, LiveAdapterHostError> {
1921 let adapter = self
1922 .adapter_for(channel_id, true)
1923 .await?;
1924 adapter
1925 .send_command(LiveAdapterCommand::SendInput { chunk })
1926 .await?;
1927 self.record_command_queue_acceptance(channel_id, LiveCommandAcceptanceKind::SendInput)
1928 .await
1929 }
1930
1931 pub async fn submit_tool_result(
1937 &self,
1938 channel_id: &LiveChannelId,
1939 result: LiveToolResult,
1940 ) -> Result<(), LiveAdapterHostError> {
1941 self.send_command(channel_id, LiveAdapterCommand::SubmitToolResult { result })
1942 .await
1943 }
1944
1945 pub async fn submit_tool_error(
1952 &self,
1953 channel_id: &LiveChannelId,
1954 call_id: ToolCallId,
1955 error: String,
1956 ) -> Result<(), LiveAdapterHostError> {
1957 self.send_command(
1958 channel_id,
1959 LiveAdapterCommand::SubmitToolError { call_id, error },
1960 )
1961 .await
1962 }
1963
1964 pub async fn submit_tool_dispatch_timeout(
1972 &self,
1973 channel_id: &LiveChannelId,
1974 call_id: ToolCallId,
1975 timeout: LiveToolDispatchTimeout,
1976 ) -> Result<(), LiveAdapterHostError> {
1977 self.submit_tool_error(channel_id, call_id, timeout.to_string())
1978 .await
1979 }
1980
1981 pub async fn next_observation_raw(
1987 &self,
1988 channel_id: &LiveChannelId,
1989 ) -> Result<Option<LiveAdapterObservation>, LiveAdapterHostError> {
1990 {
1999 let mut inner = self.inner.lock().await;
2000 if let Some(channel) = inner.channels.get_mut(channel_id)
2001 && let Some(obs) = channel.pending_synthetic_obs.take()
2002 {
2003 return Ok(Some(obs));
2004 }
2005 }
2006 let adapter = self
2007 .adapter_for(channel_id, false)
2008 .await?;
2009 match adapter.next_observation().await {
2010 Ok(Some(obs)) => Ok(Some(obs)),
2011 Ok(None) => {
2012 let mut inner = self.inner.lock().await;
2018 if let Some(channel) = inner.channels.get_mut(channel_id)
2019 && let Some(obs) = channel.pending_synthetic_obs.take()
2020 {
2021 return Ok(Some(obs));
2022 }
2023 Ok(None)
2024 }
2025 Err(err) => {
2026 let synthetic = LiveAdapterObservation::Error {
2027 code: LiveAdapterErrorCode::ProviderError,
2028 message: format!("adapter read failure: {err}"),
2029 };
2030 Ok(Some(synthetic))
2031 }
2032 }
2033 }
2034
2035 pub async fn apply_observation(
2045 &self,
2046 channel_id: &LiveChannelId,
2047 observation: &LiveAdapterObservation,
2048 ) -> Result<ObservationOutcome, LiveAdapterHostError> {
2049 if Self::observation_requires_generated_close(observation)
2050 && !self.generated_close_has_committed(channel_id).await?
2051 {
2052 return Err(LiveAdapterHostError::CloseNotAuthorized);
2053 }
2054
2055 let routing = Self::classify_observation(observation);
2056
2057 let session_id = self.channel_session(channel_id).await?;
2058
2059 match (routing, observation) {
2060 (ObservationRouting::Noop, _) => Ok(ObservationOutcome::Noop),
2061
2062 (ObservationRouting::UpdateStatus(status), _) => {
2063 Ok(ObservationOutcome::StatusUpdated(status))
2064 }
2065
2066 (
2067 ObservationRouting::AppendTranscript,
2068 LiveAdapterObservation::UserTranscriptFinal {
2069 provider_item_id,
2070 previous_item_id,
2071 content_index,
2072 text,
2073 ..
2074 },
2075 ) => {
2076 let identity = LiveTranscriptIdentity::user(
2077 provider_item_id.as_deref(),
2078 previous_item_id.as_deref(),
2079 *content_index,
2080 );
2081 self.projection_sink
2082 .append_user_transcript(&session_id, text, identity)
2083 .await?;
2084 Ok(ObservationOutcome::TranscriptAppended)
2085 }
2086
2087 (
2088 ObservationRouting::AppendTranscript,
2089 LiveAdapterObservation::AssistantTextDelta {
2090 provider_item_id,
2091 previous_item_id,
2092 content_index,
2093 response_id,
2094 delta_id,
2095 delta,
2096 ..
2097 },
2098 ) => {
2099 let identity = LiveTranscriptIdentity::assistant_delta(
2100 provider_item_id.as_deref(),
2101 previous_item_id.as_deref(),
2102 *content_index,
2103 response_id.as_deref(),
2104 delta_id.as_deref(),
2105 );
2106 self.projection_sink
2109 .append_assistant_text_delta(&session_id, delta, identity)
2110 .await?;
2111 Ok(ObservationOutcome::TranscriptAppended)
2112 }
2113
2114 (
2115 ObservationRouting::AppendTranscript,
2116 LiveAdapterObservation::AssistantTranscriptDelta {
2117 provider_item_id,
2118 previous_item_id,
2119 content_index,
2120 response_id,
2121 delta_id,
2122 delta,
2123 ..
2124 },
2125 ) => {
2126 let identity = LiveTranscriptIdentity::assistant_delta(
2127 provider_item_id.as_deref(),
2128 previous_item_id.as_deref(),
2129 *content_index,
2130 response_id.as_deref(),
2131 delta_id.as_deref(),
2132 );
2133 self.projection_sink
2136 .append_assistant_transcript_delta(&session_id, delta, identity)
2137 .await?;
2138 Ok(ObservationOutcome::TranscriptAppended)
2139 }
2140
2141 (
2142 ObservationRouting::AppendTranscript,
2143 LiveAdapterObservation::AssistantTranscriptFinal {
2144 provider_item_id,
2145 previous_item_id,
2146 content_index,
2147 response_id,
2148 text,
2149 stop_reason,
2150 usage,
2151 ..
2152 },
2153 ) => {
2154 let identity = LiveTranscriptIdentity::assistant_final(
2155 provider_item_id,
2156 previous_item_id.as_deref(),
2157 *content_index,
2158 response_id.as_deref(),
2159 );
2160 self.projection_sink
2164 .append_assistant_transcript_final(
2165 &session_id,
2166 text,
2167 identity,
2168 *stop_reason,
2169 usage.clone(),
2170 response_id.as_deref(),
2171 )
2172 .await?;
2173 Ok(ObservationOutcome::TranscriptAppended)
2174 }
2175
2176 (
2177 ObservationRouting::AppendTranscript,
2178 LiveAdapterObservation::AssistantTranscriptTruncated {
2179 provider_item_id,
2180 previous_item_id,
2181 content_index,
2182 response_id,
2183 text,
2184 },
2185 ) => {
2186 self.projection_sink
2187 .truncate_assistant_transcript(
2188 &session_id,
2189 provider_item_id.as_deref(),
2190 previous_item_id.as_deref(),
2191 *content_index,
2192 response_id.as_deref(),
2193 text.as_deref(),
2194 )
2195 .await?;
2196 Ok(ObservationOutcome::TranscriptTruncated)
2197 }
2198
2199 (
2200 ObservationRouting::AppendTranscript,
2201 LiveAdapterObservation::TurnCompleted {
2202 response_id,
2203 stop_reason,
2204 usage,
2205 },
2206 ) => {
2207 self.projection_sink
2211 .signal_turn_completed(
2212 &session_id,
2213 *stop_reason,
2214 usage.clone(),
2215 response_id.as_deref(),
2216 )
2217 .await?;
2218 Ok(ObservationOutcome::TranscriptAppended)
2219 }
2220
2221 (
2226 ObservationRouting::AppendRealtimeTranscript,
2227 LiveAdapterObservation::RealtimeTranscript { event },
2228 ) => {
2229 let outcome = self
2230 .projection_sink
2231 .append_realtime_transcript(&session_id, event)
2232 .await?;
2233 match outcome.user_content {
2234 Some(RealtimeUserContentApplyOutcome::Committed(identity))
2235 | Some(RealtimeUserContentApplyOutcome::AlreadyCommitted(identity)) => {
2236 Ok(ObservationOutcome::UserContentCommitted {
2237 observation: LiveAdapterObservation::UserContentCommitted {
2238 idempotency_key: identity.idempotency_key,
2239 item_id: identity.item_id,
2240 previous_item_id: identity.previous_item_id,
2241 content_index: identity.content_index,
2242 media_type: identity.media_type,
2243 },
2244 })
2245 }
2246 Some(RealtimeUserContentApplyOutcome::RejectedInvalidIdentity {
2247 idempotency_key,
2248 }) => {
2249 let code = LiveAdapterErrorCode::ConfigRejected {
2250 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputIdempotencyKeyInvalid {
2251 max_bytes: meerkat_core::live_adapter::MAX_LIVE_IMAGE_IDEMPOTENCY_KEY_BYTES as u64,
2252 actual_bytes: idempotency_key.len() as u64,
2253 },
2254 };
2255 self.projection_sink
2256 .signal_terminal_error(
2257 &session_id,
2258 code.clone(),
2259 "invalid durable image identity",
2260 )
2261 .await?;
2262 Ok(ObservationOutcome::Terminal { code })
2263 }
2264 Some(RealtimeUserContentApplyOutcome::RejectedUnmaterializedPredecessor {
2265 ..
2266 }) => {
2267 let code = LiveAdapterErrorCode::ConfigRejected {
2268 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputRequiresCommit,
2269 };
2270 self.projection_sink
2271 .signal_terminal_error(
2272 &session_id,
2273 code.clone(),
2274 "image predecessor is not canonical",
2275 )
2276 .await?;
2277 Ok(ObservationOutcome::Terminal { code })
2278 }
2279 Some(RealtimeUserContentApplyOutcome::RejectedConflict { .. }) => {
2280 let code = LiveAdapterErrorCode::ConfigRejected {
2281 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputIdempotencyConflict,
2282 };
2283 self.projection_sink
2284 .signal_terminal_error(
2285 &session_id,
2286 code.clone(),
2287 "image idempotency conflict after provider acknowledgement",
2288 )
2289 .await?;
2290 Ok(ObservationOutcome::Terminal { code })
2291 }
2292 None => Ok(ObservationOutcome::TranscriptAppended),
2293 }
2294 }
2295
2296 (
2297 ObservationRouting::DispatchToolCall { .. },
2298 LiveAdapterObservation::ToolCallRequested {
2299 provider_call_id,
2300 tool_name,
2301 arguments,
2302 },
2303 ) => {
2304 self.dispatch_tool_call(channel_id, provider_call_id, tool_name, arguments.clone())
2305 .await
2306 }
2307
2308 (
2309 ObservationRouting::SignalInterrupt,
2310 LiveAdapterObservation::TurnInterrupted { response_id },
2311 ) => {
2312 self.projection_sink
2313 .signal_turn_interrupt(&session_id, response_id.as_deref())
2314 .await?;
2315 Ok(ObservationOutcome::InterruptSignalled)
2316 }
2317
2318 (
2319 ObservationRouting::TerminalError,
2320 LiveAdapterObservation::Error { code, message },
2321 ) => {
2322 self.projection_sink
2323 .signal_terminal_error(&session_id, code.clone(), message)
2324 .await?;
2325 Ok(ObservationOutcome::Terminal { code: code.clone() })
2326 }
2327
2328 (
2338 ObservationRouting::CommandRejection,
2339 LiveAdapterObservation::CommandRejected { code, message },
2340 ) => Ok(ObservationOutcome::CommandRejected {
2341 code: code.clone(),
2342 message: message.clone(),
2343 }),
2344
2345 (ObservationRouting::AppendTranscript, _) => Ok(ObservationOutcome::Noop),
2350
2351 _ => Ok(ObservationOutcome::Noop),
2357 }
2358 }
2359
2360 async fn dispatch_tool_call(
2361 &self,
2362 channel_id: &LiveChannelId,
2363 provider_call_id: &ToolCallId,
2364 tool_name: &ToolName,
2365 arguments: serde_json::Value,
2366 ) -> Result<ObservationOutcome, LiveAdapterHostError> {
2367 let dispatcher = match self.load_dispatcher() {
2368 Some(d) => d,
2369 None => {
2370 let _ = self
2383 .submit_tool_error(
2384 channel_id,
2385 provider_call_id.clone(),
2386 "live tool dispatcher not configured".to_string(),
2387 )
2388 .await;
2389 return Ok(ObservationOutcome::ToolCallSkipped {
2390 provider_call_id: provider_call_id.0.clone(),
2391 tool_name: tool_name.to_string(),
2392 reason: ToolDispatchSkipReason::NoDispatcher,
2393 });
2394 }
2395 };
2396
2397 let session_id = self.channel_session(channel_id).await?;
2398 let call = ToolCall::new(provider_call_id.0.clone(), tool_name.to_string(), arguments);
2399
2400 let dispatch_call = dispatcher.dispatch_live_tool_call(&session_id, call);
2405 let timeout = self.tool_timeout;
2406 let dispatch_result = match tokio::time::timeout(timeout, dispatch_call).await {
2407 Ok(result) => result,
2408 Err(_elapsed) => {
2409 self.submit_tool_dispatch_timeout(
2419 channel_id,
2420 provider_call_id.clone(),
2421 LiveToolDispatchTimeout::new(timeout),
2422 )
2423 .await?;
2424 return Ok(ObservationOutcome::ToolCallTimedOut {
2425 provider_call_id: provider_call_id.0.clone(),
2426 tool_name: tool_name.to_string(),
2427 timeout,
2428 });
2429 }
2430 };
2431 match dispatch_result {
2432 Ok(outcome) => {
2433 let live_result =
2434 tool_result_from_dispatch(provider_call_id.clone(), outcome.result);
2435 self.submit_tool_result(channel_id, live_result).await?;
2436 }
2437 Err(err) => {
2438 self.submit_tool_error(channel_id, provider_call_id.clone(), err.to_string())
2439 .await?;
2440 }
2441 }
2442
2443 Ok(ObservationOutcome::ToolCallDispatched {
2444 provider_call_id: provider_call_id.0.clone(),
2445 tool_name: tool_name.to_string(),
2446 })
2447 }
2448
2449 async fn adapter_for(
2452 &self,
2453 channel_id: &LiveChannelId,
2454 require_ready: bool,
2455 ) -> Result<Arc<dyn LiveAdapter>, LiveAdapterHostError> {
2456 let inner = self.inner.lock().await;
2457 let channel = inner
2458 .channels
2459 .get(channel_id)
2460 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2461 if channel.retire_at.is_some() {
2462 return Err(LiveAdapterHostError::ChannelNotReady(
2465 channel_id.clone(),
2466 channel.status.clone(),
2467 ));
2468 }
2469 if require_ready && !channel.status.accepts_commands() {
2470 return Err(LiveAdapterHostError::ChannelNotReady(
2471 channel_id.clone(),
2472 channel.status.clone(),
2473 ));
2474 }
2475 let adapter = channel
2476 .adapter
2477 .as_ref()
2478 .ok_or_else(|| LiveAdapterHostError::NoAdapter(channel_id.clone()))?;
2479 Ok(Arc::clone(adapter))
2480 }
2481
2482 #[cfg(test)]
2503 pub(crate) async fn signal_terminal_error(
2504 &self,
2505 channel_id: &LiveChannelId,
2506 code: LiveAdapterErrorCode,
2507 ) -> Result<(), LiveAdapterHostError> {
2508 let observation = self
2509 .signal_terminal_error_observed(channel_id, code)
2510 .await?;
2511 self.prepare_channel_physical_close(&observation).await?;
2512 let authority = self
2513 .close_commit_authority_from_generated_test_machine(&observation)
2514 .await?;
2515 self.commit_channel_close_observation(&observation, &authority)
2516 .await
2517 }
2518
2519 pub async fn signal_terminal_error_observed(
2520 &self,
2521 channel_id: &LiveChannelId,
2522 code: LiveAdapterErrorCode,
2523 ) -> Result<LiveChannelCloseObservation, LiveAdapterHostError> {
2524 let message = match &code {
2525 LiveAdapterErrorCode::ConfigRejected { reason } => reason.to_string(),
2529 other => format!("{other:?}"),
2530 };
2531 let synthetic = LiveAdapterObservation::Error {
2532 code: code.clone(),
2533 message: message.clone(),
2534 };
2535
2536 let adapter = {
2555 let mut inner = self.inner.lock().await;
2556 let channel = inner
2557 .channels
2558 .get_mut(channel_id)
2559 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2560 channel.pending_synthetic_obs = Some(synthetic.clone());
2561 channel.adapter.clone()
2562 };
2563 if let Some(adapter) = adapter {
2564 let _ = adapter.inject_observation(synthetic).await;
2568 }
2569 self.reserve_channel_close_observation(channel_id).await
2570 }
2571
2572 pub async fn reserve_channel_close_observation(
2573 &self,
2574 channel_id: &LiveChannelId,
2575 ) -> Result<LiveChannelCloseObservation, LiveAdapterHostError> {
2576 let mut inner = self.inner.lock().await;
2577 Self::reap_retired_locked(&mut inner);
2578 let channel = inner
2579 .channels
2580 .get_mut(channel_id)
2581 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2582 channel.close_observation_sequence = channel.close_observation_sequence.saturating_add(1);
2583 LiveChannelCloseObservation::from_host_close_observation(
2584 channel_id.as_str().to_owned(),
2585 channel.close_observation_sequence,
2586 )
2587 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2588 }
2589
2590 pub async fn commit_channel_close_observation(
2591 &self,
2592 observation: &LiveChannelCloseObservation,
2593 authority: &LiveChannelCloseCommitAuthority,
2594 ) -> Result<(), LiveAdapterHostError> {
2595 if authority.channel_id() != observation.channel_id()
2596 || authority.close_sequence() != observation.close_sequence()
2597 {
2598 return Err(LiveAdapterHostError::CloseNotAuthorized);
2599 }
2600 let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2607 {
2608 let mut inner = self.inner.lock().await;
2609 Self::reap_retired_locked(&mut inner);
2610 let channel = inner
2611 .channels
2612 .get_mut(&channel_id)
2613 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2614 if !channel.physical_close_confirmed || channel.adapter.is_some() {
2615 return Err(LiveAdapterHostError::CloseNotAuthorized);
2616 }
2617 authority.consume_once()?;
2621 channel.status = LiveAdapterStatus::Closed;
2622 channel.retire_at = Some(std::time::Instant::now() + CLOSED_CHANNEL_TTL);
2623 }
2624 Ok(())
2625 }
2626
2627 pub async fn prepare_channel_physical_close(
2635 &self,
2636 observation: &LiveChannelCloseObservation,
2637 ) -> Result<(), LiveAdapterHostError> {
2638 let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2639 let adapter = {
2640 let mut inner = self.inner.lock().await;
2641 Self::reap_retired_locked(&mut inner);
2642 let channel = inner
2643 .channels
2644 .get(&channel_id)
2645 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2646 if channel.physical_close_confirmed && channel.adapter.is_none() {
2647 return Ok(());
2648 }
2649 channel
2650 .adapter
2651 .as_ref()
2652 .map(Arc::clone)
2653 .ok_or_else(|| LiveAdapterHostError::NoAdapter(channel_id.clone()))?
2654 };
2655
2656 adapter.close().await?;
2657
2658 let mut inner = self.inner.lock().await;
2659 let channel = inner
2660 .channels
2661 .get_mut(&channel_id)
2662 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2663 match channel.adapter.as_ref() {
2664 Some(current) if Arc::ptr_eq(current, &adapter) => {
2665 channel.adapter = None;
2666 channel.physical_close_confirmed = true;
2667 Ok(())
2668 }
2669 None if channel.physical_close_confirmed => Ok(()),
2670 _ => Err(LiveAdapterHostError::CloseNotAuthorized),
2671 }
2672 }
2673
2674 #[cfg(test)]
2675 pub(crate) async fn close_commit_authority_from_generated_test_machine(
2676 &self,
2677 observation: &LiveChannelCloseObservation,
2678 ) -> Result<LiveChannelCloseCommitAuthority, LiveAdapterHostError> {
2679 let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2680 let session_id = self.channel_session(&channel_id).await?;
2681 Ok(
2682 LiveChannelCloseCommitAuthority::from_generated_test_machine(
2683 &session_id,
2684 &channel_id,
2685 observation.close_sequence(),
2686 ),
2687 )
2688 }
2689
2690 #[cfg(test)]
2691 pub(crate) async fn close_channel_observed_with_generated_test_machine_authority(
2692 &self,
2693 channel_id: &LiveChannelId,
2694 ) -> Result<LiveChannelCloseObservation, LiveAdapterHostError> {
2695 let observation = self.reserve_channel_close_observation(channel_id).await?;
2696 self.prepare_channel_physical_close(&observation).await?;
2697 let authority = self
2698 .close_commit_authority_from_generated_test_machine(&observation)
2699 .await?;
2700 self.commit_channel_close_observation(&observation, &authority)
2701 .await?;
2702 Ok(observation)
2703 }
2704
2705 #[cfg(test)]
2706 pub(crate) async fn close_channel_with_generated_test_machine_authority(
2707 &self,
2708 channel_id: &LiveChannelId,
2709 ) -> Result<(), LiveAdapterHostError> {
2710 self.close_channel_observed_with_generated_test_machine_authority(channel_id)
2711 .await
2712 .map(|_| ())
2713 }
2714
2715 #[cfg(test)]
2716 pub(crate) async fn status_commit_authority_from_generated_test_machine(
2717 &self,
2718 observation: &LiveChannelStatusObservation,
2719 ) -> Result<LiveChannelStatusCommitAuthority, LiveAdapterHostError> {
2720 let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2721 let session_id = self.channel_session(&channel_id).await?;
2722 let channel_id_string = channel_id.as_str().to_owned();
2723 let (status, degradation_reason, degradation_detail) =
2724 generated_test_live_channel_status(observation.status());
2725 let mut authority = generated_test_machine_for_registered_session(&session_id);
2726 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
2727 &mut authority,
2728 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::ResolveLiveOpenAdmission {
2729 session_id: session_id.to_string(),
2730 channel_id: channel_id_string.clone(),
2731 llm_identity: generated_test_llm_identity(),
2732 },
2733 )
2734 .expect("generated MeerkatMachine live-open admission");
2735 let transition = meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineMutator::apply(
2736 &mut authority,
2737 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineInput::RecordLiveChannelStatus {
2738 channel_id: channel_id_string.clone(),
2739 status,
2740 status_observation_sequence: observation.observation_sequence(),
2741 degradation_reason,
2742 degradation_detail: degradation_detail.clone(),
2743 },
2744 )
2745 .expect("generated MeerkatMachine live-status result");
2746 assert!(
2747 transition.effects().iter().any(|effect| matches!(
2748 effect,
2749 meerkat_machine_schema::catalog::dsl::meerkat_machine::MeerkatMachineEffect::LiveChannelStatusResolved {
2750 channel_id: effect_channel_id,
2751 status: effect_status,
2752 status_observation_sequence,
2753 ..
2754 } if *effect_channel_id == channel_id_string
2755 && *effect_status == status
2756 && *status_observation_sequence == observation.observation_sequence()
2757 )),
2758 "generated live-status result effect"
2759 );
2760 Ok(LiveChannelStatusCommitAuthority::from_generated_parts(
2761 channel_id_string,
2762 observation.observation_sequence(),
2763 ))
2764 }
2765
2766 #[cfg(test)]
2767 pub(crate) async fn commit_status_with_generated_test_machine_authority(
2768 &self,
2769 channel_id: &LiveChannelId,
2770 status: LiveAdapterStatus,
2771 ) -> Result<LiveChannelStatusObservation, LiveAdapterHostError> {
2772 let observation = self
2773 .reserve_channel_status_observation(channel_id, status)
2774 .await?;
2775 let authority = self
2776 .status_commit_authority_from_generated_test_machine(&observation)
2777 .await?;
2778 self.commit_channel_status_observation(&observation, &authority)
2779 .await?;
2780 Ok(observation)
2781 }
2782
2783 pub(crate) async fn channel_status(
2790 &self,
2791 channel_id: &LiveChannelId,
2792 ) -> Result<LiveAdapterStatus, LiveAdapterHostError> {
2793 let mut inner = self.inner.lock().await;
2794 Self::reap_retired_locked(&mut inner);
2795 inner
2796 .channels
2797 .get(channel_id)
2798 .map(|ch| ch.status.clone())
2799 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2800 }
2801
2802 pub async fn channel_status_observation(
2803 &self,
2804 channel_id: &LiveChannelId,
2805 ) -> Result<LiveChannelStatusObservation, LiveAdapterHostError> {
2806 let mut inner = self.inner.lock().await;
2811 Self::reap_retired_locked(&mut inner);
2812 let channel = inner
2813 .channels
2814 .get_mut(channel_id)
2815 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2816 channel.status_observation_sequence = channel.status_observation_sequence.saturating_add(1);
2817 LiveChannelStatusObservation::from_host_status_observation(
2818 channel_id.as_str().to_owned(),
2819 channel.status.clone(),
2820 channel.status_observation_sequence,
2821 )
2822 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2823 }
2824
2825 pub async fn reserve_channel_status_observation(
2826 &self,
2827 channel_id: &LiveChannelId,
2828 status: LiveAdapterStatus,
2829 ) -> Result<LiveChannelStatusObservation, LiveAdapterHostError> {
2830 if status.is_terminal() {
2831 return Err(LiveAdapterHostError::StatusNotAuthorized);
2832 }
2833 let mut inner = self.inner.lock().await;
2834 Self::reap_retired_locked(&mut inner);
2835 let channel = inner
2836 .channels
2837 .get_mut(channel_id)
2838 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2839 if channel.retire_at.is_some() {
2840 return Err(LiveAdapterHostError::ChannelNotReady(
2841 channel_id.clone(),
2842 channel.status.clone(),
2843 ));
2844 }
2845 channel.status_observation_sequence = channel.status_observation_sequence.saturating_add(1);
2849 LiveChannelStatusObservation::from_host_status_observation(
2850 channel_id.as_str().to_owned(),
2851 status,
2852 channel.status_observation_sequence,
2853 )
2854 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2855 }
2856
2857 pub async fn commit_channel_status_observation(
2858 &self,
2859 observation: &LiveChannelStatusObservation,
2860 authority: &LiveChannelStatusCommitAuthority,
2861 ) -> Result<(), LiveAdapterHostError> {
2862 authority.consume_once()?;
2863 if authority.channel_id() != observation.channel_id()
2864 || authority.status_observation_sequence() != observation.observation_sequence()
2865 {
2866 return Err(LiveAdapterHostError::StatusNotAuthorized);
2867 }
2868 if observation.status().is_terminal() {
2869 return Err(LiveAdapterHostError::StatusNotAuthorized);
2870 }
2871 let channel_id = LiveChannelId::new(observation.channel_id().to_owned());
2872 let mut inner = self.inner.lock().await;
2873 Self::reap_retired_locked(&mut inner);
2874 let channel = inner
2875 .channels
2876 .get_mut(&channel_id)
2877 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
2878 if channel.retire_at.is_some() {
2879 return Err(LiveAdapterHostError::ChannelNotReady(
2880 channel_id,
2881 channel.status.clone(),
2882 ));
2883 }
2884 channel.status = observation.status().clone();
2885 Ok(())
2886 }
2887
2888 pub async fn channel_session(
2889 &self,
2890 channel_id: &LiveChannelId,
2891 ) -> Result<SessionId, LiveAdapterHostError> {
2892 let inner = self.inner.lock().await;
2893 inner
2894 .channels
2895 .get(channel_id)
2896 .map(|ch| ch.session_id.clone())
2897 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
2898 }
2899
2900 pub async fn signal_transport_barge_in(
2912 &self,
2913 channel_id: &LiveChannelId,
2914 ) -> Result<(), LiveAdapterHostError> {
2915 let session_id = self.channel_session(channel_id).await?;
2916 self.projection_sink
2917 .signal_turn_interrupt(&session_id, None)
2918 .await?;
2919 Ok(())
2920 }
2921
2922 pub async fn signal_output_audio_degraded(
2932 &self,
2933 channel_id: &LiveChannelId,
2934 dropped: u64,
2935 ) -> Result<(), LiveAdapterHostError> {
2936 let session_id = self.channel_session(channel_id).await?;
2937 self.projection_sink
2938 .signal_output_audio_degraded(&session_id, dropped)
2939 .await?;
2940 Ok(())
2941 }
2942
2943 pub fn classify_observation(observation: &LiveAdapterObservation) -> ObservationRouting {
2944 match observation {
2945 LiveAdapterObservation::Ready => {
2946 ObservationRouting::UpdateStatus(LiveAdapterStatus::Ready)
2947 }
2948 LiveAdapterObservation::UserTranscriptFinal { .. } => {
2949 ObservationRouting::AppendTranscript
2950 }
2951 LiveAdapterObservation::AssistantTextDelta { .. } => {
2952 ObservationRouting::AppendTranscript
2953 }
2954 LiveAdapterObservation::AssistantTranscriptDelta { .. } => {
2957 ObservationRouting::AppendTranscript
2958 }
2959 LiveAdapterObservation::AssistantAudioChunk { .. } => ObservationRouting::Noop,
2960 LiveAdapterObservation::AssistantTranscriptFinal { .. } => {
2961 ObservationRouting::AppendTranscript
2962 }
2963 LiveAdapterObservation::AssistantTranscriptTruncated { .. } => {
2964 ObservationRouting::AppendTranscript
2965 }
2966 LiveAdapterObservation::RealtimeTranscript { .. } => {
2972 ObservationRouting::AppendRealtimeTranscript
2973 }
2974 LiveAdapterObservation::UserContentCommitted { .. } => ObservationRouting::Noop,
2978 LiveAdapterObservation::ToolCallRequested {
2979 provider_call_id,
2980 tool_name,
2981 ..
2982 } => ObservationRouting::DispatchToolCall {
2983 provider_call_id: provider_call_id.0.clone(),
2986 tool_name: tool_name.to_string(),
2987 },
2988 LiveAdapterObservation::TurnInterrupted { .. } => ObservationRouting::SignalInterrupt,
2989 LiveAdapterObservation::TurnCompleted { .. } => ObservationRouting::AppendTranscript,
2990 LiveAdapterObservation::StatusChanged { status } => {
2991 ObservationRouting::UpdateStatus(status.clone())
2992 }
2993 LiveAdapterObservation::Error { .. } => ObservationRouting::TerminalError,
2994 LiveAdapterObservation::CommandRejected { .. } => ObservationRouting::CommandRejection,
3000 _ => ObservationRouting::Noop,
3001 }
3002 }
3003
3004 fn observation_requires_generated_close(observation: &LiveAdapterObservation) -> bool {
3005 match observation {
3006 LiveAdapterObservation::Error { .. } => true,
3007 LiveAdapterObservation::StatusChanged { status } => status.is_terminal(),
3008 _ => false,
3009 }
3010 }
3011
3012 pub(crate) async fn generated_close_has_committed(
3019 &self,
3020 channel_id: &LiveChannelId,
3021 ) -> Result<bool, LiveAdapterHostError> {
3022 self.channel_status(channel_id)
3023 .await
3024 .map(|status| status.is_terminal())
3025 }
3026
3027 pub async fn next_snapshot_version(
3028 &self,
3029 channel_id: &LiveChannelId,
3030 ) -> Result<u64, LiveAdapterHostError> {
3031 let mut inner = self.inner.lock().await;
3032 let channel = inner
3033 .channels
3034 .get_mut(channel_id)
3035 .ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
3036 channel.snapshot_version += 1;
3037 Ok(channel.snapshot_version)
3038 }
3039
3040 pub async fn active_channels(&self) -> Vec<LiveChannelId> {
3041 let mut inner = self.inner.lock().await;
3042 Self::reap_retired_locked(&mut inner);
3043 inner
3051 .channels
3052 .iter()
3053 .filter(|(_, ch)| ch.retire_at.is_none())
3054 .map(|(id, _)| id.clone())
3055 .collect()
3056 }
3057
3058 fn reap_retired_locked(inner: &mut HostInner) {
3060 let now = std::time::Instant::now();
3061 let to_drop: Vec<LiveChannelId> = inner
3062 .channels
3063 .iter()
3064 .filter_map(|(id, ch)| match ch.retire_at {
3065 Some(deadline) if deadline <= now => Some(id.clone()),
3066 _ => None,
3067 })
3068 .collect();
3069 for id in to_drop {
3070 if let Some(ch) = inner.channels.shift_remove(&id) {
3071 if inner
3077 .by_session
3078 .get(&ch.session_id)
3079 .is_some_and(|current| current == &id)
3080 {
3081 inner.by_session.remove(&ch.session_id);
3082 }
3083 }
3084 }
3085 }
3086}
3087
3088fn tool_result_from_dispatch(call_id: ToolCallId, result: ToolResult) -> LiveToolResult {
3093 LiveToolResult {
3094 call_id,
3097 content: result.content,
3098 is_error: result.is_error,
3099 }
3100}
3101
3102#[cfg(test)]
3103#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3104mod tests {
3105 use super::*;
3106 use async_trait::async_trait;
3107 use meerkat_core::live_adapter::{
3108 LiveAdapterError, LiveAdapterErrorCode, LiveAdapterObservation, LiveDegradationReason,
3109 };
3110 use meerkat_core::ops::ToolDispatchOutcome;
3111 use meerkat_core::types::{StopReason, Usage};
3112 use std::sync::Mutex as StdMutex;
3113
3114 fn test_session_id() -> SessionId {
3115 SessionId::new()
3116 }
3117
3118 #[test]
3125 fn tool_timeout_defaults_to_canonical_value_and_builder_overrides_it() {
3126 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3127 assert_eq!(host.tool_timeout(), DEFAULT_LIVE_TOOL_TIMEOUT);
3128
3129 let override_timeout = Duration::from_secs(7);
3130 let host =
3131 LiveAdapterHost::new(Arc::new(NoOpProjectionSink)).with_tool_timeout(override_timeout);
3132 assert_eq!(host.tool_timeout(), override_timeout);
3133 assert_eq!(DEFAULT_LIVE_TOOL_TIMEOUT, Duration::from_secs(30));
3136 }
3137
3138 #[tokio::test]
3160 async fn projection_sink_is_mandatory_at_construction() {
3161 let recording = Arc::new(RecordingProjectionSink::default());
3163 let host = LiveAdapterHost::new(Arc::clone(&recording) as _);
3164 let session = test_session_id();
3165 let ch = host
3166 .open_channel_with_generated_test_machine_authority(session.clone())
3167 .await
3168 .unwrap();
3169 let obs = LiveAdapterObservation::UserTranscriptFinal {
3170 provider_item_id: Some("item-1".into()),
3171 previous_item_id: None,
3172 content_index: Some(0),
3173 text: "hello".into(),
3174 };
3175 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
3176 assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
3177 assert_eq!(
3178 recording.user_transcripts.lock().unwrap().len(),
3179 1,
3180 "production-shape host must route user transcripts to the sink"
3181 );
3182
3183 let noop_host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3189 let session2 = test_session_id();
3190 let ch2 = noop_host
3191 .open_channel_with_generated_test_machine_authority(session2)
3192 .await
3193 .unwrap();
3194 let outcome2 = noop_host.apply_observation(&ch2, &obs).await.unwrap();
3195 assert!(matches!(outcome2, ObservationOutcome::TranscriptAppended));
3196 }
3197
3198 #[tokio::test]
3201 async fn open_channel_returns_unique_ids() {
3202 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3203 let s1 = test_session_id();
3204 let s2 = test_session_id();
3205 let ch1 = host
3206 .open_channel_with_generated_test_machine_authority(s1)
3207 .await
3208 .unwrap();
3209 let ch2 = host
3210 .open_channel_with_generated_test_machine_authority(s2)
3211 .await
3212 .unwrap();
3213 assert_ne!(ch1, ch2);
3214 }
3215
3216 #[tokio::test]
3217 async fn open_channel_ids_are_uuid_shape_not_live_n() {
3218 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3222 let ch1 = host
3223 .open_channel_with_generated_test_machine_authority(test_session_id())
3224 .await
3225 .unwrap();
3226 let ch2 = host
3227 .open_channel_with_generated_test_machine_authority(test_session_id())
3228 .await
3229 .unwrap();
3230
3231 for ch in [&ch1, &ch2] {
3232 let s = ch.as_str();
3233 assert!(
3234 !s.starts_with("live_"),
3235 "channel id retained legacy `live_N` shape: {s}"
3236 );
3237 let parsed =
3240 uuid::Uuid::parse_str(s).expect("channel id should be a valid UUID string");
3241 assert_eq!(
3242 parsed.get_version(),
3243 Some(uuid::Version::Random),
3244 "channel id should be a v4 UUID"
3245 );
3246 }
3247 assert_ne!(ch1, ch2);
3248 }
3249
3250 #[tokio::test]
3251 async fn open_channel_starts_in_opening_status() {
3252 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3253 let ch = host
3254 .open_channel_with_generated_test_machine_authority(test_session_id())
3255 .await
3256 .unwrap();
3257 let status = host.channel_status(&ch).await.unwrap();
3258 assert_eq!(status, LiveAdapterStatus::Opening);
3259 }
3260
3261 #[tokio::test]
3262 async fn channel_status_observation_advances_per_channel() {
3263 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3264 let ch = host
3265 .open_channel_with_generated_test_machine_authority(test_session_id())
3266 .await
3267 .unwrap();
3268
3269 let first = host.channel_status_observation(&ch).await.unwrap();
3270 let second = host.channel_status_observation(&ch).await.unwrap();
3271
3272 assert_eq!(first.channel_id(), ch.as_str());
3273 assert_eq!(first.status(), &LiveAdapterStatus::Opening);
3274 assert_eq!(first.observation_sequence(), 1);
3275 assert_eq!(second.observation_sequence(), 2);
3276 }
3277
3278 #[tokio::test]
3279 async fn duplicate_session_binding_rejected() {
3280 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3281 let session_id = test_session_id();
3282 let _ch = host
3283 .open_channel_with_generated_test_machine_authority(session_id.clone())
3284 .await
3285 .unwrap();
3286 let err = host
3287 .open_channel_with_generated_test_machine_authority(session_id.clone())
3288 .await
3289 .unwrap_err();
3290 assert!(matches!(err, LiveAdapterHostError::SessionAlreadyBound(id) if id == session_id));
3291 }
3292
3293 #[tokio::test]
3294 async fn close_channel_marks_closed_and_retains_for_status_reads() {
3295 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3296 let ch = host
3297 .open_channel_with_generated_test_machine_authority(test_session_id())
3298 .await
3299 .unwrap();
3300 host.close_channel_with_generated_test_machine_authority(&ch)
3301 .await
3302 .unwrap();
3303 let status = host.channel_status(&ch).await.unwrap();
3305 assert_eq!(status, LiveAdapterStatus::Closed);
3306 }
3307
3308 #[tokio::test]
3309 async fn physical_close_failure_retains_discovery_and_blocks_terminal_commit() {
3310 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3311 let ch = host
3312 .open_channel_with_generated_test_machine_authority(test_session_id())
3313 .await
3314 .unwrap();
3315 host.attach_adapter(&ch, Arc::new(FailOnceCloseAdapter::default()))
3316 .await
3317 .unwrap();
3318 let observation = host.reserve_channel_close_observation(&ch).await.unwrap();
3319
3320 assert!(matches!(
3321 host.prepare_channel_physical_close(&observation).await,
3322 Err(LiveAdapterHostError::AdapterError(_))
3323 ));
3324 assert!(
3325 host.active_channels().await.contains(&ch),
3326 "failed physical close must retain host discovery for retry"
3327 );
3328 let premature_authority = host
3329 .close_commit_authority_from_generated_test_machine(&observation)
3330 .await
3331 .unwrap();
3332 assert!(matches!(
3333 host.commit_channel_close_observation(&observation, &premature_authority)
3334 .await,
3335 Err(LiveAdapterHostError::CloseNotAuthorized)
3336 ));
3337
3338 host.prepare_channel_physical_close(&observation)
3339 .await
3340 .expect("retry physically closes the exact retained adapter");
3341 let authority = host
3342 .close_commit_authority_from_generated_test_machine(&observation)
3343 .await
3344 .unwrap();
3345 host.commit_channel_close_observation(&observation, &authority)
3346 .await
3347 .unwrap();
3348 assert_eq!(
3349 host.channel_status(&ch).await.unwrap(),
3350 LiveAdapterStatus::Closed
3351 );
3352 }
3353
3354 #[tokio::test]
3355 async fn close_channel_observation_advances_per_channel() {
3356 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3357 let ch = host
3358 .open_channel_with_generated_test_machine_authority(test_session_id())
3359 .await
3360 .unwrap();
3361
3362 let first = host
3363 .close_channel_observed_with_generated_test_machine_authority(&ch)
3364 .await
3365 .unwrap();
3366 let second = host
3367 .close_channel_observed_with_generated_test_machine_authority(&ch)
3368 .await
3369 .unwrap();
3370
3371 assert_eq!(first.channel_id(), ch.as_str());
3372 assert_eq!(first.close_sequence(), 1);
3373 assert_eq!(second.close_sequence(), 2);
3374 assert_eq!(
3375 host.channel_status(&ch).await.unwrap(),
3376 LiveAdapterStatus::Closed
3377 );
3378 }
3379
3380 #[tokio::test]
3381 async fn close_channel_allows_rebinding_same_session() {
3382 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3383 let session_id = test_session_id();
3384 let ch = host
3385 .open_channel_with_generated_test_machine_authority(session_id.clone())
3386 .await
3387 .unwrap();
3388 host.close_channel_with_generated_test_machine_authority(&ch)
3389 .await
3390 .unwrap();
3391 let ch2 = host
3392 .open_channel_with_generated_test_machine_authority(session_id)
3393 .await
3394 .unwrap();
3395 assert_ne!(ch, ch2);
3396 }
3397
3398 #[tokio::test]
3404 async fn reap_of_retired_channel_preserves_rebound_session_mapping() {
3405 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3406 let session_id = test_session_id();
3407
3408 let ch_a = host
3409 .open_channel_with_generated_test_machine_authority(session_id.clone())
3410 .await
3411 .unwrap();
3412 host.close_channel_with_generated_test_machine_authority(&ch_a)
3413 .await
3414 .unwrap();
3415 let ch_b = host
3416 .open_channel_with_generated_test_machine_authority(session_id.clone())
3417 .await
3418 .unwrap();
3419 assert_ne!(ch_a, ch_b);
3420
3421 {
3424 let mut inner = host.inner.lock().await;
3425 if let Some(channel) = inner.channels.get_mut(&ch_a) {
3426 channel.retire_at =
3427 Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
3428 }
3429 }
3430
3431 let active = host.active_channels().await;
3434 assert_eq!(active, vec![ch_b.clone()]);
3435 {
3436 let inner = host.inner.lock().await;
3437 assert_eq!(
3438 inner.by_session.get(&session_id),
3439 Some(&ch_b),
3440 "reap of retired A must not clear B's reverse mapping"
3441 );
3442 assert!(
3443 !inner.channels.contains_key(&ch_a),
3444 "retired channel A must be dropped"
3445 );
3446 assert!(
3447 inner.channels.contains_key(&ch_b),
3448 "rebound channel B must remain"
3449 );
3450 }
3451
3452 let err = host
3456 .open_channel_with_generated_test_machine_authority(session_id.clone())
3457 .await
3458 .unwrap_err();
3459 assert!(
3460 matches!(err, LiveAdapterHostError::SessionAlreadyBound(id) if id == session_id),
3461 "after reap, third open for session must still see B as bound"
3462 );
3463 assert_eq!(host.active_channels().await.len(), 1);
3464 }
3465
3466 #[tokio::test]
3475 async fn active_channels_excludes_retained_closed_channels() {
3476 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3477 let s1 = test_session_id();
3478 let s2 = test_session_id();
3479
3480 let live = host
3481 .open_channel_with_generated_test_machine_authority(s1)
3482 .await
3483 .unwrap();
3484 let closing = host
3485 .open_channel_with_generated_test_machine_authority(s2)
3486 .await
3487 .unwrap();
3488
3489 let active_pre = host.active_channels().await;
3491 assert!(active_pre.contains(&live));
3492 assert!(active_pre.contains(&closing));
3493 assert_eq!(active_pre.len(), 2);
3494
3495 host.close_channel_with_generated_test_machine_authority(&closing)
3499 .await
3500 .unwrap();
3501
3502 let active_during_ttl = host.active_channels().await;
3506 assert_eq!(
3507 active_during_ttl,
3508 vec![live.clone()],
3509 "retained-closed channel must not appear in active_channels()"
3510 );
3511 assert_eq!(
3513 host.channel_status(&closing).await.unwrap(),
3514 LiveAdapterStatus::Closed,
3515 );
3516
3517 {
3521 let mut inner = host.inner.lock().await;
3522 if let Some(channel) = inner.channels.get_mut(&closing) {
3523 channel.retire_at =
3524 Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
3525 }
3526 }
3527 let active_post_reap = host.active_channels().await;
3528 assert_eq!(active_post_reap, vec![live.clone()]);
3529 {
3530 let inner = host.inner.lock().await;
3531 assert!(
3532 !inner.channels.contains_key(&closing),
3533 "post-reap, retired channel must be dropped from the map"
3534 );
3535 }
3536 }
3537
3538 #[tokio::test]
3539 async fn channel_session_returns_bound_session() {
3540 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3541 let session_id = test_session_id();
3542 let ch = host
3543 .open_channel_with_generated_test_machine_authority(session_id.clone())
3544 .await
3545 .unwrap();
3546 assert_eq!(host.channel_session(&ch).await.unwrap(), session_id);
3547 }
3548
3549 #[tokio::test]
3556 async fn signal_terminal_error_enqueues_synthetic_error_obs_and_closes_channel() {
3557 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3558 let ch = host
3559 .open_channel_with_generated_test_machine_authority(test_session_id())
3560 .await
3561 .unwrap();
3562 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
3563 .await
3564 .unwrap();
3565
3566 let code = LiveAdapterErrorCode::ConfigRejected {
3567 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::RefreshModelSwap {
3568 from_model: "gpt-realtime".to_string(),
3569 to_model: "gpt-realtime-1.5".to_string(),
3570 },
3571 };
3572 host.signal_terminal_error(&ch, code).await.unwrap();
3573
3574 let status = host.channel_status(&ch).await.unwrap();
3576 assert_eq!(status, LiveAdapterStatus::Closed);
3577
3578 let obs = host
3582 .next_observation_raw(&ch)
3583 .await
3584 .expect("next_observation_raw should return synthetic obs even post-close")
3585 .expect("synthetic obs must be Some");
3586 match obs {
3587 LiveAdapterObservation::Error { code, message } => match code {
3588 LiveAdapterErrorCode::ConfigRejected { reason } => {
3589 assert!(matches!(
3590 reason,
3591 meerkat_core::live_adapter::LiveConfigRejectionReason::RefreshModelSwap {
3592 ref to_model,
3593 ..
3594 } if to_model == "gpt-realtime-1.5"
3595 ));
3596 assert!(message.contains("close + reopen"));
3600 }
3601 other => panic!("expected ConfigRejected, got {other:?}"),
3602 },
3603 other => panic!("expected Error observation, got {other:?}"),
3604 }
3605 }
3606
3607 #[tokio::test]
3612 async fn synthetic_terminal_error_routes_through_apply_observation_to_terminal_outcome() {
3613 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3614 let ch = host
3615 .open_channel_with_generated_test_machine_authority(test_session_id())
3616 .await
3617 .unwrap();
3618 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
3619 .await
3620 .unwrap();
3621
3622 let code = LiveAdapterErrorCode::ConfigRejected {
3623 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ChannelIdentitySwap {
3624 from_model: "a".to_string(),
3625 from_provider: meerkat_core::Provider::OpenAI,
3626 to_model: "b".to_string(),
3627 to_provider: meerkat_core::Provider::OpenAI,
3628 auth_binding_changed: false,
3629 },
3630 };
3631 host.signal_terminal_error(&ch, code).await.unwrap();
3632
3633 let obs = host.next_observation_raw(&ch).await.unwrap().unwrap();
3634 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
3635 match outcome {
3636 ObservationOutcome::Terminal { code } => match code {
3637 LiveAdapterErrorCode::ConfigRejected { reason } => {
3638 assert!(matches!(
3639 reason,
3640 meerkat_core::live_adapter::LiveConfigRejectionReason::ChannelIdentitySwap {
3641 ref from_model, ref to_model, ..
3642 } if from_model == "a" && to_model == "b"
3643 ));
3644 }
3645 other => panic!("expected ConfigRejected, got {other:?}"),
3646 },
3647 other => panic!("expected Terminal outcome, got {other:?}"),
3648 }
3649 }
3650
3651 #[tokio::test]
3660 async fn signal_terminal_error_delivers_synthetic_error_before_close_signal() {
3661 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3662 let ch = host
3663 .open_channel_with_generated_test_machine_authority(test_session_id())
3664 .await
3665 .unwrap();
3666 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
3667 .await
3668 .unwrap();
3669
3670 let code = LiveAdapterErrorCode::ConfigRejected {
3671 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::Other {
3672 detail: "model_swap_test".to_string(),
3673 },
3674 };
3675 host.signal_terminal_error(&ch, code).await.unwrap();
3676
3677 let first = host
3679 .next_observation_raw(&ch)
3680 .await
3681 .expect("first read should succeed")
3682 .expect("synthetic Error must surface before end-of-stream");
3683 match first {
3684 LiveAdapterObservation::Error { code, message } => match code {
3685 LiveAdapterErrorCode::ConfigRejected { reason } => {
3686 assert!(matches!(
3690 &reason,
3691 meerkat_core::live_adapter::LiveConfigRejectionReason::Other { detail }
3692 if detail == "model_swap_test"
3693 ));
3694 assert_eq!(message, "model_swap_test");
3695 }
3696 other => unreachable!("expected ConfigRejected, got {other:?}"),
3697 },
3698 other => unreachable!("expected Error obs first, got {other:?}"),
3699 }
3700 }
3701
3702 #[tokio::test]
3707 async fn signal_terminal_error_on_missing_channel_returns_channel_not_found() {
3708 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3709 let bogus = LiveChannelId::random_uuid();
3710 let result = host
3711 .signal_terminal_error(
3712 &bogus,
3713 LiveAdapterErrorCode::ConfigRejected {
3714 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::Other {
3715 detail: "no channel".into(),
3716 },
3717 },
3718 )
3719 .await;
3720 assert!(
3721 matches!(&result, Err(LiveAdapterHostError::ChannelNotFound(id)) if id == &bogus),
3722 "expected ChannelNotFound for unknown channel, got {result:?}"
3723 );
3724 }
3725
3726 #[test]
3729 fn ready_observation_routes_to_status_update() {
3730 let routing = LiveAdapterHost::classify_observation(&LiveAdapterObservation::Ready);
3731 assert_eq!(
3732 routing,
3733 ObservationRouting::UpdateStatus(LiveAdapterStatus::Ready)
3734 );
3735 }
3736
3737 #[test]
3738 fn tool_call_observation_routes_to_dispatch() {
3739 let obs = LiveAdapterObservation::ToolCallRequested {
3740 provider_call_id: ToolCallId::new("call_1"),
3741 tool_name: ToolName::new("calculator"),
3742 arguments: serde_json::json!({"x": 1}),
3743 };
3744 let routing = LiveAdapterHost::classify_observation(&obs);
3745 assert_eq!(
3746 routing,
3747 ObservationRouting::DispatchToolCall {
3748 provider_call_id: "call_1".into(),
3749 tool_name: "calculator".into(),
3750 }
3751 );
3752 }
3753
3754 #[test]
3755 fn barge_in_observation_routes_to_interrupt() {
3756 let routing =
3757 LiveAdapterHost::classify_observation(&LiveAdapterObservation::TurnInterrupted {
3758 response_id: None,
3759 });
3760 assert_eq!(routing, ObservationRouting::SignalInterrupt);
3761 let routing_with_id =
3762 LiveAdapterHost::classify_observation(&LiveAdapterObservation::TurnInterrupted {
3763 response_id: Some("resp_42".into()),
3764 });
3765 assert_eq!(routing_with_id, ObservationRouting::SignalInterrupt);
3766 }
3767
3768 #[test]
3769 fn user_transcript_routes_to_append() {
3770 let obs = LiveAdapterObservation::UserTranscriptFinal {
3771 provider_item_id: Some("item_1".into()),
3772 previous_item_id: None,
3773 content_index: None,
3774 text: "hello".into(),
3775 };
3776 assert_eq!(
3777 LiveAdapterHost::classify_observation(&obs),
3778 ObservationRouting::AppendTranscript
3779 );
3780 }
3781
3782 #[test]
3783 fn assistant_text_delta_routes_to_append() {
3784 let obs = LiveAdapterObservation::AssistantTextDelta {
3785 provider_item_id: Some("item_2".into()),
3786 previous_item_id: None,
3787 content_index: None,
3788 response_id: None,
3789 delta_id: None,
3790 delta: "world".into(),
3791 };
3792 assert_eq!(
3793 LiveAdapterHost::classify_observation(&obs),
3794 ObservationRouting::AppendTranscript
3795 );
3796 }
3797
3798 #[test]
3799 fn turn_completed_routes_to_append() {
3800 let obs = LiveAdapterObservation::TurnCompleted {
3801 response_id: None,
3802 stop_reason: StopReason::EndTurn,
3803 usage: Usage {
3804 input_tokens: 10,
3805 output_tokens: 5,
3806 cache_creation_tokens: None,
3807 cache_read_tokens: None,
3808 },
3809 };
3810 assert_eq!(
3811 LiveAdapterHost::classify_observation(&obs),
3812 ObservationRouting::AppendTranscript
3813 );
3814 }
3815
3816 #[test]
3817 fn error_observation_routes_to_terminal() {
3818 let obs = LiveAdapterObservation::Error {
3819 code: LiveAdapterErrorCode::ConnectionLost,
3820 message: "ws closed".into(),
3821 };
3822 assert_eq!(
3823 LiveAdapterHost::classify_observation(&obs),
3824 ObservationRouting::TerminalError
3825 );
3826 }
3827
3828 #[test]
3829 fn audio_chunk_routes_to_noop() {
3830 let obs = LiveAdapterObservation::AssistantAudioChunk {
3831 data: vec![0; 100],
3832 sample_rate_hz: 24000,
3833 channels: 1,
3834 response_id: None,
3835 item_id: None,
3836 content_index: None,
3837 };
3838 assert_eq!(
3839 LiveAdapterHost::classify_observation(&obs),
3840 ObservationRouting::Noop
3841 );
3842 }
3843
3844 #[test]
3845 fn user_content_committed_receipt_routes_to_noop() {
3846 let obs = LiveAdapterObservation::UserContentCommitted {
3847 idempotency_key: "image-request-1".into(),
3848 item_id: "item_image".into(),
3849 previous_item_id: None,
3850 content_index: 0,
3851 media_type: "image/png".into(),
3852 };
3853 assert_eq!(
3854 LiveAdapterHost::classify_observation(&obs),
3855 ObservationRouting::Noop
3856 );
3857 }
3858
3859 #[test]
3860 fn status_changed_routes_to_status_update() {
3861 let obs = LiveAdapterObservation::StatusChanged {
3862 status: LiveAdapterStatus::Degraded {
3863 reason: LiveDegradationReason::ProviderThrottled,
3864 },
3865 };
3866 assert_eq!(
3867 LiveAdapterHost::classify_observation(&obs),
3868 ObservationRouting::UpdateStatus(LiveAdapterStatus::Degraded {
3869 reason: LiveDegradationReason::ProviderThrottled,
3870 })
3871 );
3872 }
3873
3874 #[tokio::test]
3877 async fn commit_status_with_generated_authority_changes_channel_status() {
3878 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3879 let ch = host
3880 .open_channel_with_generated_test_machine_authority(test_session_id())
3881 .await
3882 .unwrap();
3883 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
3884 .await
3885 .unwrap();
3886 assert_eq!(
3887 host.channel_status(&ch).await.unwrap(),
3888 LiveAdapterStatus::Ready
3889 );
3890 }
3891
3892 #[tokio::test]
3893 async fn terminal_status_update_requires_generated_close_authority() {
3894 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3895 let ch = host
3896 .open_channel_with_generated_test_machine_authority(test_session_id())
3897 .await
3898 .unwrap();
3899 let err = host
3900 .commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Closed)
3901 .await
3902 .expect_err("terminal status update must not bypass generated close authority");
3903 assert!(matches!(err, LiveAdapterHostError::StatusNotAuthorized));
3904
3905 let obs = LiveAdapterObservation::StatusChanged {
3906 status: LiveAdapterStatus::Closed,
3907 };
3908 let err = host
3909 .apply_observation(&ch, &obs)
3910 .await
3911 .expect_err("closed observation must not bypass generated close authority");
3912 assert!(matches!(err, LiveAdapterHostError::CloseNotAuthorized));
3913
3914 host.close_channel_with_generated_test_machine_authority(&ch)
3915 .await
3916 .unwrap();
3917 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
3918 assert_eq!(
3919 outcome,
3920 ObservationOutcome::StatusUpdated(LiveAdapterStatus::Closed)
3921 );
3922 }
3923
3924 #[tokio::test]
3927 async fn snapshot_version_increments_monotonically() {
3928 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3929 let ch = host
3930 .open_channel_with_generated_test_machine_authority(test_session_id())
3931 .await
3932 .unwrap();
3933 let v1 = host.next_snapshot_version(&ch).await.unwrap();
3934 let v2 = host.next_snapshot_version(&ch).await.unwrap();
3935 assert_eq!(v1, 1);
3936 assert_eq!(v2, 2);
3937 }
3938
3939 #[tokio::test]
3942 async fn active_channels_lists_open_channels() {
3943 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3944 let ch1 = host
3945 .open_channel_with_generated_test_machine_authority(test_session_id())
3946 .await
3947 .unwrap();
3948 let ch2 = host
3949 .open_channel_with_generated_test_machine_authority(test_session_id())
3950 .await
3951 .unwrap();
3952 let active = host.active_channels().await;
3953 assert_eq!(active.len(), 2);
3954 assert!(active.contains(&ch1));
3955 assert!(active.contains(&ch2));
3956 }
3957
3958 #[tokio::test]
3961 async fn send_input_without_adapter_returns_error() {
3962 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3963 let ch = host
3964 .open_channel_with_generated_test_machine_authority(test_session_id())
3965 .await
3966 .unwrap();
3967 let err = host
3968 .send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
3969 .await
3970 .unwrap_err();
3971 assert!(matches!(err, LiveAdapterHostError::ChannelNotReady(_, _)));
3974 }
3975
3976 #[tokio::test]
3977 async fn send_command_rejects_refresh_without_typed_acceptance_path() {
3978 let session_id = test_session_id();
3979 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
3980 let ch = host
3981 .open_channel_with_generated_test_machine_authority(session_id.clone())
3982 .await
3983 .unwrap();
3984 let snapshot = meerkat_core::live_adapter::LiveProjectionSnapshot {
3985 session_id,
3986 snapshot_version: 1,
3987 seed_messages: vec![],
3988 visible_tools: vec![],
3989 system_prompt: None,
3990 model_id: "model-a".into(),
3991 provider_id: meerkat_core::Provider::Other,
3992 audio_config: None,
3993 runtime_system_context: vec![],
3994 user_content_identities: vec![],
3995 user_content_tombstones: vec![],
3996 canonical_user_image_decoded_bytes: None,
3997 transcript_rewrite_generation: 0,
3998 };
3999
4000 let err = host
4001 .send_command(&ch, LiveAdapterCommand::Refresh { snapshot })
4002 .await
4003 .unwrap_err();
4004
4005 assert!(matches!(err, LiveAdapterHostError::UnsupportedCommand(_)));
4006 }
4007
4008 #[tokio::test]
4009 async fn attach_adapter_does_not_assert_ready() {
4010 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4013 let ch = host
4014 .open_channel_with_generated_test_machine_authority(test_session_id())
4015 .await
4016 .unwrap();
4017 assert_eq!(
4018 host.channel_status(&ch).await.unwrap(),
4019 LiveAdapterStatus::Opening
4020 );
4021 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4022 .await
4023 .unwrap();
4024 assert_eq!(
4025 host.channel_status(&ch).await.unwrap(),
4026 LiveAdapterStatus::Opening,
4027 "attach_adapter must NOT mark channel Ready (F32)"
4028 );
4029 }
4030
4031 #[tokio::test]
4034 async fn send_input_rejected_when_not_ready() {
4035 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4036 let ch = host
4037 .open_channel_with_generated_test_machine_authority(test_session_id())
4038 .await
4039 .unwrap();
4040 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4041 .await
4042 .unwrap();
4043 let err = host
4045 .send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
4046 .await
4047 .unwrap_err();
4048 match err {
4049 LiveAdapterHostError::ChannelNotReady(_, status) => {
4050 assert_eq!(status, LiveAdapterStatus::Opening);
4051 }
4052 other => panic!("expected ChannelNotReady, got {other:?}"),
4053 }
4054 }
4055
4056 #[tokio::test]
4057 async fn send_input_accepts_when_ready() {
4058 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4059 let ch = host
4060 .open_channel_with_generated_test_machine_authority(test_session_id())
4061 .await
4062 .unwrap();
4063 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4064 .await
4065 .unwrap();
4066 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4067 .await
4068 .unwrap();
4069 host.send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
4070 .await
4071 .unwrap();
4072 }
4073
4074 #[tokio::test]
4077 async fn adapter_pump_error_routes_close_through_generated_authority() {
4078 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4082 let ch = host
4083 .open_channel_with_generated_test_machine_authority(test_session_id())
4084 .await
4085 .unwrap();
4086 host.attach_adapter(&ch, Arc::new(ErroringAdapter))
4087 .await
4088 .unwrap();
4089 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4090 .await
4091 .unwrap();
4092 let obs = host
4093 .next_observation_raw(&ch)
4094 .await
4095 .unwrap()
4096 .expect("synthetic Error obs surfaces on adapter Err");
4097 match &obs {
4098 LiveAdapterObservation::Error { code, message } => {
4099 assert_eq!(*code, LiveAdapterErrorCode::ProviderError);
4100 assert!(
4101 message.contains("adapter read failure"),
4102 "synthetic message must explain origin; got `{message}`"
4103 );
4104 }
4105 other => unreachable!("expected synthetic Error, got {other:?}"),
4106 }
4107 let err = host
4108 .apply_observation(&ch, &obs)
4109 .await
4110 .expect_err("terminal observation must wait for generated close authority");
4111 assert!(matches!(err, LiveAdapterHostError::CloseNotAuthorized));
4112
4113 let status = host.channel_status(&ch).await.unwrap();
4115 assert_eq!(status, LiveAdapterStatus::Ready);
4116 {
4117 let inner = host.inner.lock().await;
4118 let channel = inner
4119 .channels
4120 .get(&ch)
4121 .expect("channel remains present before close authority");
4122 assert!(
4123 channel.retire_at.is_none(),
4124 "adapter Err must not set retire_at before generated close authority"
4125 );
4126 assert!(
4127 channel.adapter.is_some(),
4128 "adapter Err must not drop the adapter before generated close authority"
4129 );
4130 }
4131
4132 host.close_channel_with_generated_test_machine_authority(&ch)
4133 .await
4134 .unwrap();
4135 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4136 assert_eq!(
4137 outcome,
4138 ObservationOutcome::Terminal {
4139 code: LiveAdapterErrorCode::ProviderError
4140 }
4141 );
4142 let status = host.channel_status(&ch).await.unwrap();
4143 assert_eq!(status, LiveAdapterStatus::Closed);
4144 {
4145 let inner = host.inner.lock().await;
4146 let channel = inner
4147 .channels
4148 .get(&ch)
4149 .expect("channel preserved for live/status until TTL elapses");
4150 assert!(
4151 channel.retire_at.is_some(),
4152 "generated close authority must set retire_at"
4153 );
4154 assert!(
4155 channel.adapter.is_none(),
4156 "generated close authority must drop the adapter Arc"
4157 );
4158 }
4159 }
4160
4161 #[tokio::test]
4168 async fn command_rejected_routes_non_terminally_and_preserves_channel() {
4169 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4170 let ch = host
4171 .open_channel_with_generated_test_machine_authority(test_session_id())
4172 .await
4173 .unwrap();
4174 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
4175 .await
4176 .unwrap();
4177 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4178 .await
4179 .unwrap();
4180
4181 let obs = LiveAdapterObservation::CommandRejected {
4182 code: LiveAdapterErrorCode::ConfigRejected {
4183 reason:
4184 meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputNotImplemented,
4185 },
4186 message: "image_input_not_implemented".into(),
4187 };
4188 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4189 match outcome {
4190 ObservationOutcome::CommandRejected { code, message } => {
4191 assert!(matches!(
4192 code,
4193 LiveAdapterErrorCode::ConfigRejected {
4194 reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputNotImplemented,
4195 }
4196 ));
4197 assert_eq!(message, "image_input_not_implemented");
4198 }
4199 other => {
4200 unreachable!("CommandRejected must produce CommandRejected outcome, got {other:?}")
4201 }
4202 }
4203
4204 let status = host.channel_status(&ch).await.unwrap();
4207 assert_eq!(status, LiveAdapterStatus::Ready);
4208 {
4209 let inner = host.inner.lock().await;
4210 let channel = inner.channels.get(&ch).expect("channel present");
4211 assert!(
4212 channel.retire_at.is_none(),
4213 "CommandRejected must not retire the channel"
4214 );
4215 assert!(
4216 channel.adapter.is_some(),
4217 "CommandRejected must not drop the adapter"
4218 );
4219 }
4220 }
4221
4222 #[tokio::test]
4229 async fn adapter_err_releases_session_for_rebind() {
4230 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
4231 let session_id = test_session_id();
4232 let ch1 = host
4233 .open_channel_with_generated_test_machine_authority(session_id.clone())
4234 .await
4235 .unwrap();
4236 host.attach_adapter(&ch1, Arc::new(ErroringAdapter))
4237 .await
4238 .unwrap();
4239 host.commit_status_with_generated_test_machine_authority(&ch1, LiveAdapterStatus::Ready)
4240 .await
4241 .unwrap();
4242
4243 let _ = host.next_observation_raw(&ch1).await.unwrap();
4245
4246 {
4249 let mut inner = host.inner.lock().await;
4250 if let Some(channel) = inner.channels.get_mut(&ch1) {
4251 channel.retire_at =
4252 Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
4253 }
4254 }
4255
4256 let ch2 = host
4257 .open_channel_with_generated_test_machine_authority(session_id.clone())
4258 .await
4259 .expect("rebind for same session must succeed once previous channel is retired");
4260 assert_ne!(ch1, ch2);
4261 }
4262
4263 #[tokio::test]
4266 async fn user_transcript_observation_appends_to_sink() {
4267 let sink = Arc::new(RecordingProjectionSink::default());
4268 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4269 let session_id = test_session_id();
4270 let ch = host
4271 .open_channel_with_generated_test_machine_authority(session_id.clone())
4272 .await
4273 .unwrap();
4274 let obs = LiveAdapterObservation::UserTranscriptFinal {
4275 provider_item_id: Some("item_1".into()),
4276 previous_item_id: None,
4277 content_index: None,
4278 text: "hello world".into(),
4279 };
4280 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4281 assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4282 let user = sink.user_transcripts.lock().unwrap();
4283 assert_eq!(user.len(), 1);
4284 assert_eq!(user[0].0, session_id);
4285 assert_eq!(user[0].1, "hello world");
4286 assert_eq!(user[0].2.provider_item_id.as_deref(), Some("item_1"));
4287 }
4288
4289 #[tokio::test]
4290 async fn user_transcript_full_identity_propagates_end_to_end() {
4291 let sink = Arc::new(RecordingProjectionSink::default());
4294 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4295 let session_id = test_session_id();
4296 let ch = host
4297 .open_channel_with_generated_test_machine_authority(session_id.clone())
4298 .await
4299 .unwrap();
4300 let obs = LiveAdapterObservation::UserTranscriptFinal {
4301 provider_item_id: Some("item_1".into()),
4302 previous_item_id: Some("item_0".into()),
4303 content_index: Some(2),
4304 text: "hello".into(),
4305 };
4306 host.apply_observation(&ch, &obs).await.unwrap();
4307 let user = sink.user_transcripts.lock().unwrap();
4308 let identity = &user[0].2;
4309 assert_eq!(identity.provider_item_id.as_deref(), Some("item_1"));
4310 assert_eq!(identity.previous_item_id.as_deref(), Some("item_0"));
4311 assert_eq!(identity.content_index, Some(2));
4312 assert_eq!(identity.response_id, None);
4315 assert_eq!(identity.delta_id, None);
4316 }
4317
4318 #[tokio::test]
4319 async fn assistant_text_delta_observation_appends_to_sink() {
4320 let sink = Arc::new(RecordingProjectionSink::default());
4321 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4322 let session_id = test_session_id();
4323 let ch = host
4324 .open_channel_with_generated_test_machine_authority(session_id.clone())
4325 .await
4326 .unwrap();
4327 let obs = LiveAdapterObservation::AssistantTextDelta {
4328 provider_item_id: None,
4329 previous_item_id: None,
4330 content_index: None,
4331 response_id: None,
4332 delta_id: None,
4333 delta: "Hello".into(),
4334 };
4335 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4336 assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4337 let deltas = sink.text_deltas.lock().unwrap();
4339 assert_eq!(deltas.len(), 1);
4340 assert_eq!(deltas[0].0, session_id);
4341 assert_eq!(deltas[0].1, "Hello");
4342 assert!(sink.transcript_deltas.lock().unwrap().is_empty());
4344 }
4345
4346 #[tokio::test]
4347 async fn assistant_text_delta_full_identity_propagates_end_to_end() {
4348 let sink = Arc::new(RecordingProjectionSink::default());
4351 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4352 let ch = host
4353 .open_channel_with_generated_test_machine_authority(test_session_id())
4354 .await
4355 .unwrap();
4356 let obs = LiveAdapterObservation::AssistantTextDelta {
4357 provider_item_id: Some("item_42".into()),
4358 previous_item_id: Some("item_41".into()),
4359 content_index: Some(1),
4360 response_id: Some("resp_xyz".into()),
4361 delta_id: Some("d_7".into()),
4362 delta: "world".into(),
4363 };
4364 host.apply_observation(&ch, &obs).await.unwrap();
4365 let deltas = sink.text_deltas.lock().unwrap();
4366 let identity = &deltas[0].2;
4367 assert_eq!(identity.provider_item_id.as_deref(), Some("item_42"));
4368 assert_eq!(identity.previous_item_id.as_deref(), Some("item_41"));
4369 assert_eq!(identity.content_index, Some(1));
4370 assert_eq!(identity.response_id.as_deref(), Some("resp_xyz"));
4371 assert_eq!(identity.delta_id.as_deref(), Some("d_7"));
4372 }
4373
4374 #[tokio::test]
4375 async fn assistant_transcript_final_observation_calls_sink() {
4376 let sink = Arc::new(RecordingProjectionSink::default());
4377 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4378 let session_id = test_session_id();
4379 let ch = host
4380 .open_channel_with_generated_test_machine_authority(session_id.clone())
4381 .await
4382 .unwrap();
4383 let obs = LiveAdapterObservation::AssistantTranscriptFinal {
4384 provider_item_id: "resp_1".into(),
4385 previous_item_id: None,
4386 content_index: None,
4387 response_id: None,
4388 text: "All done.".into(),
4389 stop_reason: StopReason::EndTurn,
4390 usage: Usage::default(),
4391 };
4392 host.apply_observation(&ch, &obs).await.unwrap();
4393 let finals = sink.transcript_finals.lock().unwrap();
4395 assert_eq!(finals.len(), 1);
4396 assert_eq!(finals[0].0, session_id);
4397 assert_eq!(finals[0].1, "All done.");
4398 assert!(sink.text_finals.lock().unwrap().is_empty());
4399 }
4400
4401 #[tokio::test]
4402 async fn assistant_transcript_final_full_identity_propagates_end_to_end() {
4403 let sink = Arc::new(RecordingProjectionSink::default());
4407 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4408 let ch = host
4409 .open_channel_with_generated_test_machine_authority(test_session_id())
4410 .await
4411 .unwrap();
4412 let obs = LiveAdapterObservation::AssistantTranscriptFinal {
4413 provider_item_id: "item_final".into(),
4414 previous_item_id: Some("item_prev".into()),
4415 content_index: Some(0),
4416 response_id: Some("resp_final".into()),
4417 text: "done".into(),
4418 stop_reason: StopReason::EndTurn,
4419 usage: Usage::default(),
4420 };
4421 host.apply_observation(&ch, &obs).await.unwrap();
4422 let finals = sink.transcript_finals.lock().unwrap();
4423 let identity = &finals[0].2;
4424 assert_eq!(identity.provider_item_id.as_deref(), Some("item_final"));
4425 assert_eq!(identity.previous_item_id.as_deref(), Some("item_prev"));
4426 assert_eq!(identity.content_index, Some(0));
4427 assert_eq!(identity.response_id.as_deref(), Some("resp_final"));
4428 assert_eq!(identity.delta_id, None);
4429 }
4430
4431 #[tokio::test]
4432 async fn turn_completed_observation_signals_sink() {
4433 let sink = Arc::new(RecordingProjectionSink::default());
4434 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4435 let ch = host
4436 .open_channel_with_generated_test_machine_authority(test_session_id())
4437 .await
4438 .unwrap();
4439 let obs = LiveAdapterObservation::TurnCompleted {
4440 response_id: None,
4441 stop_reason: StopReason::EndTurn,
4442 usage: Usage::default(),
4443 };
4444 host.apply_observation(&ch, &obs).await.unwrap();
4445 let turns = sink.turn_completed.lock().unwrap();
4446 assert_eq!(turns.len(), 1);
4447 }
4448
4449 #[tokio::test]
4452 async fn assistant_transcript_delta_routes_to_transcript_lane() {
4453 let sink = Arc::new(RecordingProjectionSink::default());
4458 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4459 let session_id = test_session_id();
4460 let ch = host
4461 .open_channel_with_generated_test_machine_authority(session_id.clone())
4462 .await
4463 .unwrap();
4464 let obs = LiveAdapterObservation::AssistantTranscriptDelta {
4465 provider_item_id: Some("item_t".into()),
4466 previous_item_id: None,
4467 content_index: Some(0),
4468 response_id: Some("resp_t".into()),
4469 delta_id: Some("d_t".into()),
4470 delta: "spoken word".into(),
4471 };
4472 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4473 assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4474 let transcript_deltas = sink.transcript_deltas.lock().unwrap();
4475 assert_eq!(transcript_deltas.len(), 1);
4476 assert_eq!(transcript_deltas[0].0, session_id);
4477 assert_eq!(transcript_deltas[0].1, "spoken word");
4478 assert!(
4480 sink.text_deltas.lock().unwrap().is_empty(),
4481 "AssistantTranscriptDelta must not reach the text-lane sink (T6)"
4482 );
4483 }
4484
4485 #[tokio::test]
4496 async fn realtime_transcript_observation_routes_to_append_realtime_transcript() {
4497 use meerkat_core::RealtimeTranscriptRole;
4498 let sink = Arc::new(RecordingProjectionSink::default());
4499 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4500 let session_id = test_session_id();
4501 let ch = host
4502 .open_channel_with_generated_test_machine_authority(session_id.clone())
4503 .await
4504 .unwrap();
4505
4506 let event = RealtimeTranscriptEvent::ItemObserved {
4507 item_id: "item_realtime_1".into(),
4508 previous_item_id: Some("item_realtime_0".into()),
4509 role: RealtimeTranscriptRole::Assistant,
4510 response_id: Some("resp_realtime_1".into()),
4511 };
4512 let obs = LiveAdapterObservation::RealtimeTranscript {
4513 event: event.clone(),
4514 };
4515
4516 let routing = LiveAdapterHost::classify_observation(&obs);
4519 match routing {
4520 ObservationRouting::AppendRealtimeTranscript => {}
4521 other => panic!("expected AppendRealtimeTranscript, got {other:?}"),
4522 }
4523
4524 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4526 assert!(
4527 matches!(outcome, ObservationOutcome::TranscriptAppended),
4528 "expected TranscriptAppended, got {outcome:?}"
4529 );
4530
4531 let recorded = sink.realtime_events.lock().unwrap();
4532 assert_eq!(recorded.len(), 1, "sink must see exactly one append");
4533 assert_eq!(recorded[0].0, session_id);
4534 assert_eq!(recorded[0].1, event);
4535
4536 assert!(sink.text_deltas.lock().unwrap().is_empty());
4539 assert!(sink.transcript_deltas.lock().unwrap().is_empty());
4540 assert!(sink.text_finals.lock().unwrap().is_empty());
4541 assert!(sink.transcript_finals.lock().unwrap().is_empty());
4542 assert!(sink.user_transcripts.lock().unwrap().is_empty());
4543 assert!(sink.turn_completed.lock().unwrap().is_empty());
4544 assert!(sink.interrupts.lock().unwrap().is_empty());
4545 }
4546
4547 #[tokio::test]
4554 async fn noop_projection_sink_explicitly_accepts_realtime_transcript() {
4555 use meerkat_core::RealtimeTranscriptRole;
4556 let sink: Arc<dyn LiveProjectionSink> = Arc::new(NoOpProjectionSink);
4557 let host = LiveAdapterHost::new(Arc::clone(&sink));
4558 let session_id = test_session_id();
4559 let ch = host
4560 .open_channel_with_generated_test_machine_authority(session_id.clone())
4561 .await
4562 .unwrap();
4563
4564 let event = RealtimeTranscriptEvent::ItemObserved {
4565 item_id: "item_noop".into(),
4566 previous_item_id: None,
4567 role: RealtimeTranscriptRole::Assistant,
4568 response_id: Some("resp_noop".into()),
4569 };
4570 let obs = LiveAdapterObservation::RealtimeTranscript {
4571 event: event.clone(),
4572 };
4573
4574 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4579 assert!(
4580 matches!(outcome, ObservationOutcome::TranscriptAppended),
4581 "NoOpProjectionSink must accept RealtimeTranscript explicitly, got {outcome:?}"
4582 );
4583
4584 sink.append_realtime_transcript(&session_id, &event)
4587 .await
4588 .expect("NoOpProjectionSink::append_realtime_transcript must be explicit Ok");
4589 }
4590
4591 #[tokio::test]
4592 async fn realtime_transcript_assistant_turn_completed_routes_through_sink() {
4593 let sink = Arc::new(RecordingProjectionSink::default());
4597 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4598 let ch = host
4599 .open_channel_with_generated_test_machine_authority(test_session_id())
4600 .await
4601 .unwrap();
4602 let event = RealtimeTranscriptEvent::AssistantTurnCompleted {
4603 response_id: "resp_complete".into(),
4604 stop_reason: StopReason::EndTurn,
4605 usage: Usage::default(),
4606 };
4607 let obs = LiveAdapterObservation::RealtimeTranscript {
4608 event: event.clone(),
4609 };
4610 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4611 assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
4612 let recorded = sink.realtime_events.lock().unwrap();
4613 assert_eq!(recorded.len(), 1);
4614 assert_eq!(recorded[0].1, event);
4615 }
4616
4617 #[tokio::test]
4620 async fn tool_call_observation_dispatches_through_tool_authority() {
4621 let sink = Arc::new(RecordingProjectionSink::default());
4622 let dispatcher = Arc::new(RecordingDispatcher::default());
4623 let adapter = Arc::new(RecordingAdapter::default());
4624 let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4625 .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _);
4626 let ch = host
4627 .open_channel_with_generated_test_machine_authority(test_session_id())
4628 .await
4629 .unwrap();
4630 host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4631 .await
4632 .unwrap();
4633 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4634 .await
4635 .unwrap();
4636 let obs = LiveAdapterObservation::ToolCallRequested {
4637 provider_call_id: ToolCallId::new("call_42"),
4638 tool_name: ToolName::new("calculator"),
4639 arguments: serde_json::json!({"a": 2, "b": 3}),
4640 };
4641 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4642 match outcome {
4643 ObservationOutcome::ToolCallDispatched {
4644 provider_call_id,
4645 tool_name,
4646 } => {
4647 assert_eq!(provider_call_id, "call_42");
4648 assert_eq!(tool_name, "calculator");
4649 }
4650 other => panic!("expected ToolCallDispatched, got {other:?}"),
4651 }
4652 let calls = dispatcher.calls.lock().unwrap();
4654 assert_eq!(calls.len(), 1);
4655 assert_eq!(calls[0].0, "call_42");
4656 assert_eq!(calls[0].1, "calculator");
4657 let submitted = adapter.submitted_results.lock().unwrap();
4659 assert_eq!(submitted.len(), 1);
4660 assert_eq!(submitted[0].call_id.0, "call_42");
4661 }
4662
4663 #[tokio::test]
4664 async fn tool_call_skipped_when_no_dispatcher_wired() {
4665 let sink = Arc::new(RecordingProjectionSink::default());
4666 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4667 let ch = host
4668 .open_channel_with_generated_test_machine_authority(test_session_id())
4669 .await
4670 .unwrap();
4671 let obs = LiveAdapterObservation::ToolCallRequested {
4672 provider_call_id: ToolCallId::new("call_99"),
4673 tool_name: ToolName::new("calculator"),
4674 arguments: serde_json::json!({}),
4675 };
4676 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4677 match outcome {
4678 ObservationOutcome::ToolCallSkipped {
4679 reason: ToolDispatchSkipReason::NoDispatcher,
4680 ..
4681 } => {}
4682 other => panic!("expected ToolCallSkipped/NoDispatcher, got {other:?}"),
4683 }
4684 }
4685
4686 #[tokio::test]
4698 async fn tool_call_no_dispatcher_submits_error_to_adapter() {
4699 let sink = Arc::new(RecordingProjectionSink::default());
4700 let adapter = Arc::new(RecordingAdapter::default());
4701 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4703 let ch = host
4704 .open_channel_with_generated_test_machine_authority(test_session_id())
4705 .await
4706 .unwrap();
4707 host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4708 .await
4709 .unwrap();
4710 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4716 .await
4717 .unwrap();
4718
4719 let obs = LiveAdapterObservation::ToolCallRequested {
4720 provider_call_id: ToolCallId::new("call_unwired"),
4721 tool_name: ToolName::new("calculator"),
4722 arguments: serde_json::json!({}),
4723 };
4724 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
4725
4726 match outcome {
4728 ObservationOutcome::ToolCallSkipped {
4729 provider_call_id,
4730 tool_name,
4731 reason: ToolDispatchSkipReason::NoDispatcher,
4732 } => {
4733 assert_eq!(provider_call_id, "call_unwired");
4734 assert_eq!(tool_name, "calculator");
4735 }
4736 other => panic!("expected ToolCallSkipped/NoDispatcher, got {other:?}"),
4737 }
4738
4739 let errors = adapter.submitted_errors.lock().unwrap();
4743 assert_eq!(
4744 errors.len(),
4745 1,
4746 "adapter must receive exactly one SubmitToolError when dispatcher is missing"
4747 );
4748 assert_eq!(errors[0].0, "call_unwired");
4749 assert!(
4750 errors[0].1.contains("dispatcher"),
4751 "error message should mention the missing dispatcher; got {:?}",
4752 errors[0].1
4753 );
4754
4755 assert!(adapter.submitted_results.lock().unwrap().is_empty());
4757 }
4758
4759 #[tokio::test]
4760 async fn set_live_tool_dispatcher_late_binds_after_construction() {
4761 let sink = Arc::new(RecordingProjectionSink::default());
4766 let dispatcher = Arc::new(RecordingDispatcher::default());
4767 let adapter = Arc::new(RecordingAdapter::default());
4768 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
4769 let ch = host
4770 .open_channel_with_generated_test_machine_authority(test_session_id())
4771 .await
4772 .unwrap();
4773 host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4774 .await
4775 .unwrap();
4776 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4777 .await
4778 .unwrap();
4779
4780 let obs = LiveAdapterObservation::ToolCallRequested {
4781 provider_call_id: ToolCallId::new("call_pre"),
4782 tool_name: ToolName::new("calc"),
4783 arguments: serde_json::json!({}),
4784 };
4785 match host.apply_observation(&ch, &obs).await.unwrap() {
4787 ObservationOutcome::ToolCallSkipped {
4788 reason: ToolDispatchSkipReason::NoDispatcher,
4789 ..
4790 } => {}
4791 other => panic!("expected pre-set skip, got {other:?}"),
4792 }
4793 assert_eq!(dispatcher.calls.lock().unwrap().len(), 0);
4794
4795 host.set_live_tool_dispatcher(Arc::clone(&dispatcher) as _);
4797 let obs2 = LiveAdapterObservation::ToolCallRequested {
4798 provider_call_id: ToolCallId::new("call_post"),
4799 tool_name: ToolName::new("calc"),
4800 arguments: serde_json::json!({"x": 1}),
4801 };
4802 match host.apply_observation(&ch, &obs2).await.unwrap() {
4803 ObservationOutcome::ToolCallDispatched {
4804 provider_call_id, ..
4805 } => {
4806 assert_eq!(provider_call_id, "call_post");
4807 }
4808 other => panic!("expected post-set dispatch, got {other:?}"),
4809 }
4810 let calls = dispatcher.calls.lock().unwrap();
4811 assert_eq!(calls.len(), 1);
4812 assert_eq!(calls[0].0, "call_post");
4813 }
4814
4815 #[tokio::test]
4816 async fn set_live_tool_dispatcher_replaces_previously_installed_dispatcher() {
4817 let sink = Arc::new(RecordingProjectionSink::default());
4821 let first = Arc::new(RecordingDispatcher::default());
4822 let second = Arc::new(RecordingDispatcher::default());
4823 let adapter = Arc::new(RecordingAdapter::default());
4824 let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4825 .with_live_tool_dispatcher(Arc::clone(&first) as _);
4826 let ch = host
4827 .open_channel_with_generated_test_machine_authority(test_session_id())
4828 .await
4829 .unwrap();
4830 host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4831 .await
4832 .unwrap();
4833 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4834 .await
4835 .unwrap();
4836
4837 host.set_live_tool_dispatcher(Arc::clone(&second) as _);
4838 let obs = LiveAdapterObservation::ToolCallRequested {
4839 provider_call_id: ToolCallId::new("call_swap"),
4840 tool_name: ToolName::new("calc"),
4841 arguments: serde_json::json!({}),
4842 };
4843 host.apply_observation(&ch, &obs).await.unwrap();
4844
4845 assert_eq!(first.calls.lock().unwrap().len(), 0);
4846 assert_eq!(second.calls.lock().unwrap().len(), 1);
4847 }
4848
4849 #[tokio::test]
4850 async fn tool_call_dispatch_error_submits_tool_error_to_adapter() {
4851 let sink = Arc::new(RecordingProjectionSink::default());
4852 let dispatcher = Arc::new(FailingDispatcher);
4853 let adapter = Arc::new(RecordingAdapter::default());
4854 let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4855 .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _);
4856 let ch = host
4857 .open_channel_with_generated_test_machine_authority(test_session_id())
4858 .await
4859 .unwrap();
4860 host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4861 .await
4862 .unwrap();
4863 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4864 .await
4865 .unwrap();
4866 let obs = LiveAdapterObservation::ToolCallRequested {
4867 provider_call_id: ToolCallId::new("call_err"),
4868 tool_name: ToolName::new("failing"),
4869 arguments: serde_json::json!({}),
4870 };
4871 host.apply_observation(&ch, &obs).await.unwrap();
4872 let errors = adapter.submitted_errors.lock().unwrap();
4873 assert_eq!(errors.len(), 1);
4874 assert_eq!(errors[0].0, "call_err");
4875 }
4876
4877 struct SlowDispatcher {
4898 sleep_for: Duration,
4899 calls: StdMutex<u32>,
4900 }
4901
4902 impl SlowDispatcher {
4903 fn new(sleep_for: Duration) -> Self {
4904 Self {
4905 sleep_for,
4906 calls: StdMutex::new(0),
4907 }
4908 }
4909 }
4910
4911 #[async_trait]
4912 impl LiveToolDispatcher for SlowDispatcher {
4913 async fn dispatch_live_tool_call(
4914 &self,
4915 _session_id: &SessionId,
4916 call: ToolCall,
4917 ) -> Result<ToolDispatchOutcome, LiveToolDispatchError> {
4918 *self.calls.lock().unwrap() += 1;
4919 tokio::time::sleep(self.sleep_for).await;
4920 let tool_result = meerkat_core::types::ToolResult::new(call.id, "ok".into(), false);
4922 Ok(ToolDispatchOutcome::from(tool_result))
4923 }
4924 }
4925
4926 #[tokio::test(start_paused = true)]
4927 async fn realtime_tool_timeout() {
4928 let timeout = Duration::from_millis(500);
4932 let dispatcher_sleep = Duration::from_secs(60);
4933 let sink = Arc::new(RecordingProjectionSink::default());
4934 let dispatcher = Arc::new(SlowDispatcher::new(dispatcher_sleep));
4935 let adapter = Arc::new(RecordingAdapter::default());
4936 let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
4937 .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _)
4938 .with_tool_timeout(timeout);
4939 let ch = host
4940 .open_channel_with_generated_test_machine_authority(test_session_id())
4941 .await
4942 .unwrap();
4943 host.attach_adapter(&ch, Arc::clone(&adapter) as _)
4944 .await
4945 .unwrap();
4946 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
4947 .await
4948 .unwrap();
4949
4950 let obs = LiveAdapterObservation::ToolCallRequested {
4951 provider_call_id: ToolCallId::new("call_slow"),
4952 tool_name: ToolName::new("slow_tool"),
4953 arguments: serde_json::json!({"q": 1}),
4954 };
4955
4956 let host_call = async { host.apply_observation(&ch, &obs).await.unwrap() };
4961 let drive_clock = async {
4962 tokio::task::yield_now().await;
4965 tokio::time::advance(timeout + Duration::from_millis(1)).await;
4966 };
4967 let (outcome, _) = tokio::join!(host_call, drive_clock);
4968
4969 match outcome {
4970 ObservationOutcome::ToolCallTimedOut {
4971 provider_call_id,
4972 tool_name,
4973 timeout: t,
4974 } => {
4975 assert_eq!(provider_call_id, "call_slow");
4976 assert_eq!(tool_name, "slow_tool");
4977 assert_eq!(t, timeout);
4978 }
4979 other => panic!("expected ToolCallTimedOut, got {other:?}"),
4980 }
4981
4982 assert_eq!(*dispatcher.calls.lock().unwrap(), 1);
4984
4985 let errors = adapter.submitted_errors.lock().unwrap();
4987 assert_eq!(errors.len(), 1);
4988 assert_eq!(errors[0].0, "call_slow");
4989 assert_eq!(
4995 errors[0].1,
4996 LiveToolDispatchTimeout::new(timeout).to_string(),
4997 "tool dispatch timeout must submit the typed fact's Display projection, \
4998 not a fabricated string: {}",
4999 errors[0].1
5000 );
5001 let results = adapter.submitted_results.lock().unwrap();
5002 assert!(
5003 results.is_empty(),
5004 "no SubmitToolResult should reach the adapter on timeout: {results:?}"
5005 );
5006
5007 assert_eq!(sink.text_finals.lock().unwrap().len(), 0);
5009 assert_eq!(sink.transcript_finals.lock().unwrap().len(), 0);
5010 assert_eq!(sink.terminal_errors.lock().unwrap().len(), 0);
5011 }
5012
5013 #[tokio::test(start_paused = true)]
5014 async fn tool_call_dispatch_succeeds_when_within_deadline() {
5015 let timeout = Duration::from_secs(5);
5020 let dispatcher_sleep = Duration::from_millis(100);
5021 let sink = Arc::new(RecordingProjectionSink::default());
5022 let dispatcher = Arc::new(SlowDispatcher::new(dispatcher_sleep));
5023 let adapter = Arc::new(RecordingAdapter::default());
5024 let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
5025 .with_live_tool_dispatcher(Arc::clone(&dispatcher) as _)
5026 .with_tool_timeout(timeout);
5027 let ch = host
5028 .open_channel_with_generated_test_machine_authority(test_session_id())
5029 .await
5030 .unwrap();
5031 host.attach_adapter(&ch, Arc::clone(&adapter) as _)
5032 .await
5033 .unwrap();
5034 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
5035 .await
5036 .unwrap();
5037
5038 let obs = LiveAdapterObservation::ToolCallRequested {
5039 provider_call_id: ToolCallId::new("call_fast"),
5040 tool_name: ToolName::new("fast_tool"),
5041 arguments: serde_json::json!({}),
5042 };
5043 let host_call = async { host.apply_observation(&ch, &obs).await.unwrap() };
5044 let drive_clock = async {
5045 tokio::task::yield_now().await;
5046 tokio::time::advance(dispatcher_sleep + Duration::from_millis(10)).await;
5048 };
5049 let (outcome, _) = tokio::join!(host_call, drive_clock);
5050
5051 match outcome {
5052 ObservationOutcome::ToolCallDispatched {
5053 provider_call_id, ..
5054 } => assert_eq!(provider_call_id, "call_fast"),
5055 other => panic!("expected ToolCallDispatched, got {other:?}"),
5056 }
5057 let results = adapter.submitted_results.lock().unwrap();
5058 assert_eq!(results.len(), 1);
5059 assert_eq!(results[0].call_id.0, "call_fast");
5060 assert!(adapter.submitted_errors.lock().unwrap().is_empty());
5061 }
5062
5063 #[tokio::test]
5066 async fn barge_in_observation_calls_signal_interrupt() {
5067 let sink = Arc::new(RecordingProjectionSink::default());
5068 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5069 let session_id = test_session_id();
5070 let ch = host
5071 .open_channel_with_generated_test_machine_authority(session_id.clone())
5072 .await
5073 .unwrap();
5074 let outcome = host
5075 .apply_observation(
5076 &ch,
5077 &LiveAdapterObservation::TurnInterrupted {
5078 response_id: Some("resp_42".into()),
5079 },
5080 )
5081 .await
5082 .unwrap();
5083 assert!(matches!(outcome, ObservationOutcome::InterruptSignalled));
5084 let interrupts = sink.interrupts.lock().unwrap();
5085 assert_eq!(interrupts.len(), 1);
5086 assert_eq!(interrupts[0].0, session_id);
5087 assert_eq!(interrupts[0].1.as_deref(), Some("resp_42"));
5091 }
5092
5093 #[tokio::test]
5096 async fn transport_barge_in_emits_typed_interrupt_via_sink() {
5097 let sink = Arc::new(RecordingProjectionSink::default());
5103 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5104 let session_id = test_session_id();
5105 let ch = host
5106 .open_channel_with_generated_test_machine_authority(session_id.clone())
5107 .await
5108 .unwrap();
5109
5110 host.signal_transport_barge_in(&ch).await.unwrap();
5111
5112 let interrupts = sink.interrupts.lock().unwrap();
5113 assert_eq!(
5114 interrupts.len(),
5115 1,
5116 "barge-in must signal exactly one interrupt"
5117 );
5118 assert_eq!(interrupts[0].0, session_id);
5119 assert_eq!(interrupts[0].1, None);
5122 }
5123
5124 #[tokio::test]
5128 async fn transport_output_audio_degradation_emits_typed_signal_via_sink() {
5129 let sink = Arc::new(RecordingProjectionSink::default());
5136 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5137 let session_id = test_session_id();
5138 let ch = host
5139 .open_channel_with_generated_test_machine_authority(session_id.clone())
5140 .await
5141 .unwrap();
5142
5143 host.signal_output_audio_degraded(&ch, 3).await.unwrap();
5144
5145 {
5146 let degraded = sink.output_audio_degraded.lock().unwrap();
5147 assert_eq!(
5148 degraded.as_slice(),
5149 &[(session_id, 3)],
5150 "degradation must surface exactly once with the cumulative drop count"
5151 );
5152 }
5153
5154 let missing = host
5156 .signal_output_audio_degraded(&LiveChannelId::new("no-such-channel"), 1)
5157 .await;
5158 assert!(matches!(
5159 missing,
5160 Err(LiveAdapterHostError::ChannelNotFound(_))
5161 ));
5162 }
5163
5164 #[tokio::test]
5167 async fn terminal_error_observation_signals_sink_without_closing_host_directly() {
5168 let sink = Arc::new(RecordingProjectionSink::default());
5169 let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
5170 let session_id = test_session_id();
5171 let ch = host
5172 .open_channel_with_generated_test_machine_authority(session_id.clone())
5173 .await
5174 .unwrap();
5175 let obs = LiveAdapterObservation::Error {
5176 code: LiveAdapterErrorCode::ConnectionLost,
5177 message: "ws closed unexpectedly".into(),
5178 };
5179 let err = host
5180 .apply_observation(&ch, &obs)
5181 .await
5182 .expect_err("terminal error must wait for generated close authority");
5183 assert!(matches!(err, LiveAdapterHostError::CloseNotAuthorized));
5184 assert_eq!(
5185 host.channel_status(&ch).await.unwrap(),
5186 LiveAdapterStatus::Opening
5187 );
5188 assert_eq!(sink.terminal_errors.lock().unwrap().len(), 0);
5189
5190 let close = host
5191 .close_channel_observed_with_generated_test_machine_authority(&ch)
5192 .await
5193 .unwrap();
5194 assert_eq!(close.channel_id(), ch.as_str());
5195
5196 let outcome = host.apply_observation(&ch, &obs).await.unwrap();
5197 match outcome {
5198 ObservationOutcome::Terminal {
5199 code: LiveAdapterErrorCode::ConnectionLost,
5200 } => {}
5201 other => panic!("expected Terminal/ConnectionLost, got {other:?}"),
5202 }
5203 let status = host.channel_status(&ch).await.unwrap();
5204 assert_eq!(status, LiveAdapterStatus::Closed);
5205 let terminals = sink.terminal_errors.lock().unwrap();
5207 assert_eq!(terminals.len(), 1);
5208 assert_eq!(terminals[0].0, session_id);
5209 assert!(matches!(
5210 terminals[0].1,
5211 LiveAdapterErrorCode::ConnectionLost
5212 ));
5213 }
5214
5215 #[tokio::test]
5218 async fn duplicate_session_check_uses_reverse_map() {
5219 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
5223 let s = test_session_id();
5224 let ch = host
5225 .open_channel_with_generated_test_machine_authority(s.clone())
5226 .await
5227 .unwrap();
5228 host.close_channel_with_generated_test_machine_authority(&ch)
5229 .await
5230 .unwrap();
5231 host.open_channel_with_generated_test_machine_authority(s)
5233 .await
5234 .unwrap();
5235 }
5236
5237 #[tokio::test]
5238 async fn transport_send_input_does_not_mint_command_acceptance_evidence() {
5239 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
5240 let ch = host
5241 .open_channel_with_generated_test_machine_authority(test_session_id())
5242 .await
5243 .unwrap();
5244 host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
5245 .await
5246 .unwrap();
5247 host.commit_status_with_generated_test_machine_authority(&ch, LiveAdapterStatus::Ready)
5248 .await
5249 .unwrap();
5250
5251 host.send_input(
5252 &ch,
5253 LiveInputChunk::Text {
5254 text: "hello".into(),
5255 },
5256 )
5257 .await
5258 .unwrap();
5259 {
5260 let inner = host.inner.lock().await;
5261 let channel = inner.channels.get(&ch).unwrap();
5262 assert_eq!(channel.command_acceptance_sequence, 0);
5263 }
5264
5265 let acceptance = host
5266 .send_input_observed(
5267 &ch,
5268 LiveInputChunk::Text {
5269 text: "hello".into(),
5270 },
5271 )
5272 .await
5273 .unwrap();
5274 assert_eq!(acceptance.kind(), LiveCommandAcceptanceKind::SendInput);
5275 assert_eq!(acceptance.acceptance_sequence(), 1);
5276 }
5277
5278 #[tokio::test]
5279 async fn rejected_live_command_does_not_mint_acceptance_evidence() {
5280 let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
5281 let ch = host
5282 .open_channel_with_generated_test_machine_authority(test_session_id())
5283 .await
5284 .unwrap();
5285 host.attach_adapter(&ch, Arc::new(RejectingCommandAdapter))
5286 .await
5287 .unwrap();
5288
5289 let result = host
5290 .send_command_observed(&ch, LiveAdapterCommand::Interrupt)
5291 .await;
5292
5293 assert!(
5294 matches!(
5295 result,
5296 Err(LiveAdapterHostError::AdapterError(
5297 LiveAdapterError::TransportError { .. }
5298 ))
5299 ),
5300 "adapter rejection must surface before public command acceptance"
5301 );
5302 let inner = host.inner.lock().await;
5303 let channel = inner.channels.get(&ch).unwrap();
5304 assert_eq!(
5305 channel.command_acceptance_sequence, 0,
5306 "rejected command must not mint authority evidence that WebRTC could use to discard output"
5307 );
5308 }
5309
5310 struct StubAdapter;
5315
5316 impl StubAdapter {
5317 fn new() -> Self {
5318 Self
5319 }
5320 }
5321
5322 #[derive(Default)]
5323 struct FailOnceCloseAdapter {
5324 closes: std::sync::atomic::AtomicUsize,
5325 }
5326
5327 #[async_trait]
5328 impl LiveAdapter for FailOnceCloseAdapter {
5329 async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5330 Ok(())
5331 }
5332
5333 async fn next_observation(
5334 &self,
5335 ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5336 Ok(None)
5337 }
5338
5339 fn status(&self) -> LiveAdapterStatus {
5340 LiveAdapterStatus::Ready
5341 }
5342
5343 async fn close(&self) -> Result<(), LiveAdapterError> {
5344 if self
5345 .closes
5346 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
5347 == 0
5348 {
5349 Err(LiveAdapterError::TransportError {
5350 message: "scripted first physical close failure".to_string(),
5351 })
5352 } else {
5353 Ok(())
5354 }
5355 }
5356 }
5357
5358 #[async_trait]
5359 impl LiveAdapter for StubAdapter {
5360 async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5361 Ok(())
5362 }
5363
5364 async fn next_observation(
5365 &self,
5366 ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5367 Ok(None)
5368 }
5369
5370 fn status(&self) -> LiveAdapterStatus {
5371 LiveAdapterStatus::Ready
5372 }
5373
5374 async fn close(&self) -> Result<(), LiveAdapterError> {
5375 Ok(())
5376 }
5377 }
5378
5379 struct ErroringAdapter;
5381
5382 #[async_trait]
5383 impl LiveAdapter for ErroringAdapter {
5384 async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5385 Ok(())
5386 }
5387
5388 async fn next_observation(
5389 &self,
5390 ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5391 Err(LiveAdapterError::TransportError {
5392 message: "pump dead".into(),
5393 })
5394 }
5395
5396 fn status(&self) -> LiveAdapterStatus {
5397 LiveAdapterStatus::Closed
5398 }
5399
5400 async fn close(&self) -> Result<(), LiveAdapterError> {
5401 Ok(())
5402 }
5403 }
5404
5405 struct RejectingCommandAdapter;
5406
5407 #[async_trait]
5408 impl LiveAdapter for RejectingCommandAdapter {
5409 async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5410 Err(LiveAdapterError::TransportError {
5411 message: "command queue rejected".into(),
5412 })
5413 }
5414
5415 async fn next_observation(
5416 &self,
5417 ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5418 Ok(None)
5419 }
5420
5421 fn status(&self) -> LiveAdapterStatus {
5422 LiveAdapterStatus::Ready
5423 }
5424
5425 async fn close(&self) -> Result<(), LiveAdapterError> {
5426 Ok(())
5427 }
5428 }
5429
5430 #[derive(Default)]
5433 struct RecordingAdapter {
5434 submitted_results: StdMutex<Vec<LiveToolResult>>,
5435 submitted_errors: StdMutex<Vec<(String, String)>>,
5436 }
5437
5438 #[async_trait]
5439 impl LiveAdapter for RecordingAdapter {
5440 async fn send_command(&self, command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
5441 match command {
5442 LiveAdapterCommand::SubmitToolResult { result } => {
5443 self.submitted_results.lock().unwrap().push(result);
5444 }
5445 LiveAdapterCommand::SubmitToolError { call_id, error } => {
5446 self.submitted_errors
5447 .lock()
5448 .unwrap()
5449 .push((call_id.0, error));
5450 }
5451 _ => {}
5452 }
5453 Ok(())
5454 }
5455
5456 async fn next_observation(
5457 &self,
5458 ) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
5459 Ok(None)
5460 }
5461
5462 fn status(&self) -> LiveAdapterStatus {
5463 LiveAdapterStatus::Ready
5464 }
5465
5466 async fn close(&self) -> Result<(), LiveAdapterError> {
5467 Ok(())
5468 }
5469 }
5470
5471 #[derive(Default)]
5474 struct RecordingDispatcher {
5475 calls: StdMutex<Vec<(String, String, String)>>,
5477 }
5478
5479 #[async_trait]
5480 impl LiveToolDispatcher for RecordingDispatcher {
5481 async fn dispatch_live_tool_call(
5482 &self,
5483 _session_id: &SessionId,
5484 call: ToolCall,
5485 ) -> Result<ToolDispatchOutcome, LiveToolDispatchError> {
5486 self.calls.lock().unwrap().push((
5487 call.id.clone(),
5488 call.name.clone(),
5489 call.args.to_string(),
5490 ));
5491 let tool_result = meerkat_core::types::ToolResult::new(call.id, "ok".into(), false);
5492 Ok(ToolDispatchOutcome::from(tool_result))
5493 }
5494 }
5495
5496 struct FailingDispatcher;
5498
5499 #[async_trait]
5500 impl LiveToolDispatcher for FailingDispatcher {
5501 async fn dispatch_live_tool_call(
5502 &self,
5503 _session_id: &SessionId,
5504 _call: ToolCall,
5505 ) -> Result<ToolDispatchOutcome, LiveToolDispatchError> {
5506 Err(LiveToolDispatchError::Tool(
5507 meerkat_core::error::ToolError::ExecutionFailed {
5508 message: "bang".into(),
5509 },
5510 ))
5511 }
5512 }
5513
5514 #[derive(Debug, Clone, Default, PartialEq, Eq)]
5517 struct OwnedIdentity {
5518 provider_item_id: Option<String>,
5519 previous_item_id: Option<String>,
5520 content_index: Option<u32>,
5521 response_id: Option<String>,
5522 delta_id: Option<String>,
5523 }
5524
5525 impl OwnedIdentity {
5526 fn from_borrowed(identity: LiveTranscriptIdentity<'_>) -> Self {
5527 Self {
5528 provider_item_id: identity.provider_item_id.map(|s| s.to_string()),
5529 previous_item_id: identity.previous_item_id.map(|s| s.to_string()),
5530 content_index: identity.content_index,
5531 response_id: identity.response_id.map(|s| s.to_string()),
5532 delta_id: identity.delta_id.map(|s| s.to_string()),
5533 }
5534 }
5535 }
5536
5537 #[derive(Default)]
5541 #[allow(clippy::type_complexity)]
5542 struct RecordingProjectionSink {
5543 user_transcripts: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
5544 text_deltas: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
5547 transcript_deltas: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
5548 text_finals: StdMutex<
5549 Vec<(
5550 SessionId,
5551 String,
5552 OwnedIdentity,
5553 StopReason,
5554 Usage,
5555 Option<String>,
5556 )>,
5557 >,
5558 transcript_finals: StdMutex<
5559 Vec<(
5560 SessionId,
5561 String,
5562 OwnedIdentity,
5563 StopReason,
5564 Usage,
5565 Option<String>,
5566 )>,
5567 >,
5568 truncations: StdMutex<
5569 Vec<(
5570 SessionId,
5571 Option<String>,
5572 Option<String>,
5573 Option<u32>,
5574 Option<String>,
5575 Option<String>,
5576 )>,
5577 >,
5578 interrupts: StdMutex<Vec<(SessionId, Option<String>)>>,
5579 output_audio_degraded: StdMutex<Vec<(SessionId, u64)>>,
5580 turn_completed: StdMutex<Vec<(SessionId, StopReason, Usage, Option<String>)>>,
5581 terminal_errors: StdMutex<Vec<(SessionId, LiveAdapterErrorCode, String)>>,
5582 realtime_events: StdMutex<Vec<(SessionId, RealtimeTranscriptEvent)>>,
5583 }
5584
5585 #[async_trait]
5586 impl LiveProjectionSink for RecordingProjectionSink {
5587 async fn append_user_transcript(
5588 &self,
5589 session_id: &SessionId,
5590 text: &str,
5591 identity: LiveTranscriptIdentity<'_>,
5592 ) -> Result<(), LiveProjectionError> {
5593 self.user_transcripts.lock().unwrap().push((
5594 session_id.clone(),
5595 text.to_string(),
5596 OwnedIdentity::from_borrowed(identity),
5597 ));
5598 Ok(())
5599 }
5600
5601 async fn append_assistant_text_delta(
5602 &self,
5603 session_id: &SessionId,
5604 delta: &str,
5605 identity: LiveTranscriptIdentity<'_>,
5606 ) -> Result<(), LiveProjectionError> {
5607 self.text_deltas.lock().unwrap().push((
5608 session_id.clone(),
5609 delta.to_string(),
5610 OwnedIdentity::from_borrowed(identity),
5611 ));
5612 Ok(())
5613 }
5614
5615 async fn append_assistant_transcript_delta(
5616 &self,
5617 session_id: &SessionId,
5618 delta: &str,
5619 identity: LiveTranscriptIdentity<'_>,
5620 ) -> Result<(), LiveProjectionError> {
5621 self.transcript_deltas.lock().unwrap().push((
5622 session_id.clone(),
5623 delta.to_string(),
5624 OwnedIdentity::from_borrowed(identity),
5625 ));
5626 Ok(())
5627 }
5628
5629 async fn append_assistant_text_final(
5630 &self,
5631 session_id: &SessionId,
5632 text: &str,
5633 identity: LiveTranscriptIdentity<'_>,
5634 stop_reason: StopReason,
5635 usage: Usage,
5636 response_id: Option<&str>,
5637 ) -> Result<(), LiveProjectionError> {
5638 self.text_finals.lock().unwrap().push((
5639 session_id.clone(),
5640 text.to_string(),
5641 OwnedIdentity::from_borrowed(identity),
5642 stop_reason,
5643 usage,
5644 response_id.map(|s| s.to_string()),
5645 ));
5646 Ok(())
5647 }
5648
5649 async fn append_assistant_transcript_final(
5650 &self,
5651 session_id: &SessionId,
5652 text: &str,
5653 identity: LiveTranscriptIdentity<'_>,
5654 stop_reason: StopReason,
5655 usage: Usage,
5656 response_id: Option<&str>,
5657 ) -> Result<(), LiveProjectionError> {
5658 self.transcript_finals.lock().unwrap().push((
5659 session_id.clone(),
5660 text.to_string(),
5661 OwnedIdentity::from_borrowed(identity),
5662 stop_reason,
5663 usage,
5664 response_id.map(|s| s.to_string()),
5665 ));
5666 Ok(())
5667 }
5668
5669 async fn truncate_assistant_transcript(
5670 &self,
5671 session_id: &SessionId,
5672 provider_item_id: Option<&str>,
5673 previous_item_id: Option<&str>,
5674 content_index: Option<u32>,
5675 response_id: Option<&str>,
5676 text: Option<&str>,
5677 ) -> Result<(), LiveProjectionError> {
5678 self.truncations.lock().unwrap().push((
5679 session_id.clone(),
5680 provider_item_id.map(|s| s.to_string()),
5681 previous_item_id.map(|s| s.to_string()),
5682 content_index,
5683 response_id.map(|s| s.to_string()),
5684 text.map(|s| s.to_string()),
5685 ));
5686 Ok(())
5687 }
5688
5689 async fn signal_turn_interrupt(
5690 &self,
5691 session_id: &SessionId,
5692 response_id: Option<&str>,
5693 ) -> Result<(), LiveProjectionError> {
5694 self.interrupts
5695 .lock()
5696 .unwrap()
5697 .push((session_id.clone(), response_id.map(|s| s.to_string())));
5698 Ok(())
5699 }
5700
5701 async fn signal_output_audio_degraded(
5702 &self,
5703 session_id: &SessionId,
5704 dropped: u64,
5705 ) -> Result<(), LiveProjectionError> {
5706 self.output_audio_degraded
5707 .lock()
5708 .unwrap()
5709 .push((session_id.clone(), dropped));
5710 Ok(())
5711 }
5712
5713 async fn signal_turn_completed(
5714 &self,
5715 session_id: &SessionId,
5716 stop_reason: StopReason,
5717 usage: Usage,
5718 response_id: Option<&str>,
5719 ) -> Result<(), LiveProjectionError> {
5720 self.turn_completed.lock().unwrap().push((
5721 session_id.clone(),
5722 stop_reason,
5723 usage,
5724 response_id.map(|s| s.to_string()),
5725 ));
5726 Ok(())
5727 }
5728
5729 async fn signal_terminal_error(
5730 &self,
5731 session_id: &SessionId,
5732 code: LiveAdapterErrorCode,
5733 message: &str,
5734 ) -> Result<(), LiveProjectionError> {
5735 self.terminal_errors.lock().unwrap().push((
5736 session_id.clone(),
5737 code,
5738 message.to_string(),
5739 ));
5740 Ok(())
5741 }
5742
5743 async fn append_realtime_transcript(
5744 &self,
5745 session_id: &SessionId,
5746 event: &RealtimeTranscriptEvent,
5747 ) -> Result<RealtimeTranscriptApplyOutcome, LiveProjectionError> {
5748 self.realtime_events
5749 .lock()
5750 .unwrap()
5751 .push((session_id.clone(), event.clone()));
5752 Ok(RealtimeTranscriptApplyOutcome::default())
5753 }
5754 }
5755
5756 #[test]
5761 fn session_error_classification_lands_in_distinct_typed_variants() {
5762 use meerkat_core::SessionError;
5763
5764 let sid = test_session_id();
5765
5766 assert!(matches!(
5768 LiveProjectionError::from_session_error(
5769 &sid,
5770 SessionError::NotFound { id: sid.clone() }
5771 ),
5772 LiveProjectionError::SessionNotFound(_)
5773 ));
5774
5775 match LiveProjectionError::from_session_error(
5777 &sid,
5778 SessionError::Unsupported("nope".into()),
5779 ) {
5780 LiveProjectionError::Rejected(reason) => assert_eq!(reason, "nope"),
5781 other => panic!("expected Rejected, got {other:?}"),
5782 }
5783
5784 assert!(matches!(
5786 LiveProjectionError::from_session_error(&sid, SessionError::Busy { id: sid.clone() }),
5787 LiveProjectionError::SessionBusy(_)
5788 ));
5789
5790 assert!(matches!(
5792 LiveProjectionError::from_session_error(
5793 &sid,
5794 SessionError::NotRunning { id: sid.clone() }
5795 ),
5796 LiveProjectionError::SessionNotRunning(_)
5797 ));
5798
5799 match LiveProjectionError::from_session_error(&sid, SessionError::PersistenceDisabled) {
5802 LiveProjectionError::CapabilityDisabled { code, .. } => {
5803 assert_eq!(code, "SESSION_PERSISTENCE_DISABLED");
5804 }
5805 other => panic!("expected CapabilityDisabled, got {other:?}"),
5806 }
5807 match LiveProjectionError::from_session_error(&sid, SessionError::CompactionDisabled) {
5808 LiveProjectionError::CapabilityDisabled { code, .. } => {
5809 assert_eq!(code, "SESSION_COMPACTION_DISABLED");
5810 }
5811 other => panic!("expected CapabilityDisabled, got {other:?}"),
5812 }
5813
5814 let store_err: Box<dyn std::error::Error + Send + Sync> = "disk gone".into();
5817 match LiveProjectionError::from_session_error(&sid, SessionError::Store(store_err)) {
5818 LiveProjectionError::Session { code, .. } => {
5819 assert_eq!(code, "SESSION_STORE_ERROR");
5820 }
5821 other => panic!("expected typed Session, got {other:?}"),
5822 }
5823
5824 match LiveProjectionError::from_session_error(
5827 &sid,
5828 SessionError::FailedWithData {
5829 message: "boom".into(),
5830 data: serde_json::json!({"k": "v"}),
5831 },
5832 ) {
5833 LiveProjectionError::Session { code, message } => {
5834 assert_eq!(code, "SESSION_ERROR");
5835 assert_eq!(message, "boom");
5836 }
5837 other => panic!("expected typed Session, got {other:?}"),
5838 }
5839 }
5840
5841 #[test]
5850 fn live_tool_dispatch_session_error_classification_lands_in_distinct_typed_variants() {
5851 use meerkat_core::SessionError;
5852
5853 let sid = test_session_id();
5854
5855 assert!(matches!(
5857 LiveToolDispatchError::from_session_error(
5858 &sid,
5859 SessionError::NotFound { id: sid.clone() }
5860 ),
5861 LiveToolDispatchError::SessionNotFound(_)
5862 ));
5863
5864 match LiveToolDispatchError::from_session_error(
5867 &sid,
5868 SessionError::Unsupported("no live tool dispatcher".into()),
5869 ) {
5870 LiveToolDispatchError::Rejected(reason) => {
5871 assert_eq!(reason, "no live tool dispatcher");
5872 }
5873 other => panic!("expected Rejected, got {other:?}"),
5874 }
5875
5876 assert!(matches!(
5878 LiveToolDispatchError::from_session_error(&sid, SessionError::Busy { id: sid.clone() }),
5879 LiveToolDispatchError::SessionBusy(_)
5880 ));
5881
5882 assert!(matches!(
5884 LiveToolDispatchError::from_session_error(
5885 &sid,
5886 SessionError::NotRunning { id: sid.clone() }
5887 ),
5888 LiveToolDispatchError::SessionNotRunning(_)
5889 ));
5890
5891 match LiveToolDispatchError::from_session_error(&sid, SessionError::PersistenceDisabled) {
5894 LiveToolDispatchError::CapabilityDisabled { code, .. } => {
5895 assert_eq!(code, "SESSION_PERSISTENCE_DISABLED");
5896 }
5897 other => panic!("expected CapabilityDisabled, got {other:?}"),
5898 }
5899
5900 let store_err: Box<dyn std::error::Error + Send + Sync> = "disk gone".into();
5903 match LiveToolDispatchError::from_session_error(&sid, SessionError::Store(store_err)) {
5904 LiveToolDispatchError::Session { code, .. } => {
5905 assert_eq!(code, "SESSION_STORE_ERROR");
5906 }
5907 other => panic!("expected typed Session, got {other:?}"),
5908 }
5909 }
5910
5911 #[test]
5916 fn delta_identity_requires_response_delta_and_item_ids() {
5917 let full = LiveTranscriptIdentity::assistant_delta(
5919 Some("item-1"),
5920 Some("prev-0"),
5921 Some(2),
5922 Some("resp-9"),
5923 Some("delta-3"),
5924 );
5925 let resolved = full
5926 .require_delta_identity()
5927 .expect("full identity resolves");
5928 assert_eq!(resolved.response_id, "resp-9");
5929 assert_eq!(resolved.delta_id, "delta-3");
5930 assert_eq!(resolved.item_id, "item-1");
5931 assert_eq!(resolved.previous_item_id, Some("prev-0"));
5932 assert_eq!(resolved.content_index, Some(2));
5933
5934 let no_resp = LiveTranscriptIdentity::assistant_delta(
5937 Some("item-1"),
5938 None,
5939 Some(0),
5940 None,
5941 Some("delta-3"),
5942 );
5943 assert_eq!(
5944 no_resp.require_delta_identity(),
5945 Err(LiveTranscriptIdentityError::MissingResponseId)
5946 );
5947
5948 let no_delta = LiveTranscriptIdentity::assistant_delta(
5950 Some("item-1"),
5951 None,
5952 Some(0),
5953 Some("resp-9"),
5954 None,
5955 );
5956 assert_eq!(
5957 no_delta.require_delta_identity(),
5958 Err(LiveTranscriptIdentityError::MissingDeltaId)
5959 );
5960
5961 let no_item = LiveTranscriptIdentity::assistant_delta(
5963 None,
5964 None,
5965 Some(0),
5966 Some("resp-9"),
5967 Some("delta-3"),
5968 );
5969 assert_eq!(
5970 no_item.require_delta_identity(),
5971 Err(LiveTranscriptIdentityError::MissingItemId)
5972 );
5973 }
5974}