use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use indexmap::IndexMap;
use meerkat_core::AgentToolDispatcher;
use meerkat_core::RealtimeTranscriptEvent;
use meerkat_core::SessionLlmIdentity;
use meerkat_core::ToolDispatchOutcome;
use meerkat_core::ToolError;
use meerkat_core::live_adapter::{
LiveAdapter, LiveAdapterCommand, LiveAdapterError, LiveAdapterErrorCode,
LiveAdapterObservation, LiveAdapterStatus, LiveInputChunk, LiveToolResult,
};
use meerkat_core::types::{SessionId, StopReason, ToolCall, ToolResult, Usage};
use serde_json::value::RawValue;
use tokio::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LiveChannelId(String);
impl LiveChannelId {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn random_uuid() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for LiveChannelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ObservationOutcome {
Noop,
StatusUpdated(LiveAdapterStatus),
TranscriptAppended,
TranscriptTruncated,
ToolCallDispatched {
provider_call_id: String,
tool_name: String,
},
ToolCallSkipped {
provider_call_id: String,
tool_name: String,
reason: ToolDispatchSkipReason,
},
ToolCallTimedOut {
provider_call_id: String,
tool_name: String,
timeout: std::time::Duration,
},
InterruptSignalled,
Terminal { code: LiveAdapterErrorCode },
CommandRejected {
code: LiveAdapterErrorCode,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ToolDispatchSkipReason {
NoDispatcher,
InvalidArguments,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LiveTranscriptIdentity<'a> {
pub provider_item_id: Option<&'a str>,
pub previous_item_id: Option<&'a str>,
pub content_index: Option<u32>,
pub response_id: Option<&'a str>,
pub delta_id: Option<&'a str>,
}
impl<'a> LiveTranscriptIdentity<'a> {
pub fn user(
provider_item_id: Option<&'a str>,
previous_item_id: Option<&'a str>,
content_index: Option<u32>,
) -> Self {
Self {
provider_item_id,
previous_item_id,
content_index,
response_id: None,
delta_id: None,
}
}
pub fn assistant_delta(
provider_item_id: Option<&'a str>,
previous_item_id: Option<&'a str>,
content_index: Option<u32>,
response_id: Option<&'a str>,
delta_id: Option<&'a str>,
) -> Self {
Self {
provider_item_id,
previous_item_id,
content_index,
response_id,
delta_id,
}
}
pub fn assistant_final(
provider_item_id: &'a str,
previous_item_id: Option<&'a str>,
content_index: Option<u32>,
response_id: Option<&'a str>,
) -> Self {
Self {
provider_item_id: Some(provider_item_id),
previous_item_id,
content_index,
response_id,
delta_id: None,
}
}
}
#[async_trait::async_trait]
pub trait LiveProjectionSink: Send + Sync {
async fn append_user_transcript(
&self,
session_id: &SessionId,
text: &str,
identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError>;
async fn append_assistant_text_delta(
&self,
session_id: &SessionId,
delta: &str,
identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError>;
async fn append_assistant_transcript_delta(
&self,
session_id: &SessionId,
delta: &str,
identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError>;
async fn append_assistant_text_final(
&self,
session_id: &SessionId,
text: &str,
identity: LiveTranscriptIdentity<'_>,
stop_reason: StopReason,
usage: Usage,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError>;
async fn append_assistant_transcript_final(
&self,
session_id: &SessionId,
text: &str,
identity: LiveTranscriptIdentity<'_>,
stop_reason: StopReason,
usage: Usage,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError>;
async fn truncate_assistant_transcript(
&self,
session_id: &SessionId,
provider_item_id: Option<&str>,
previous_item_id: Option<&str>,
content_index: Option<u32>,
response_id: Option<&str>,
text: Option<&str>,
) -> Result<(), LiveProjectionError>;
async fn signal_turn_interrupt(
&self,
session_id: &SessionId,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError>;
async fn signal_turn_completed(
&self,
session_id: &SessionId,
stop_reason: StopReason,
usage: Usage,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError>;
async fn signal_terminal_error(
&self,
session_id: &SessionId,
code: LiveAdapterErrorCode,
message: &str,
) -> Result<(), LiveProjectionError>;
async fn append_realtime_transcript(
&self,
session_id: &SessionId,
event: &RealtimeTranscriptEvent,
) -> Result<(), LiveProjectionError>;
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum LiveProjectionError {
#[error("session {0} not found")]
SessionNotFound(SessionId),
#[error("projection rejected: {0}")]
Rejected(String),
#[error("projection sink internal error: {0}")]
Internal(String),
}
#[doc(hidden)]
#[derive(Debug, Default)]
pub struct NoOpProjectionSink;
#[async_trait::async_trait]
impl LiveProjectionSink for NoOpProjectionSink {
async fn append_user_transcript(
&self,
_session_id: &SessionId,
_text: &str,
_identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn append_assistant_text_delta(
&self,
_session_id: &SessionId,
_delta: &str,
_identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn append_assistant_transcript_delta(
&self,
_session_id: &SessionId,
_delta: &str,
_identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn append_assistant_text_final(
&self,
_session_id: &SessionId,
_text: &str,
_identity: LiveTranscriptIdentity<'_>,
_stop_reason: StopReason,
_usage: Usage,
_response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn append_assistant_transcript_final(
&self,
_session_id: &SessionId,
_text: &str,
_identity: LiveTranscriptIdentity<'_>,
_stop_reason: StopReason,
_usage: Usage,
_response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn truncate_assistant_transcript(
&self,
_session_id: &SessionId,
_provider_item_id: Option<&str>,
_previous_item_id: Option<&str>,
_content_index: Option<u32>,
_response_id: Option<&str>,
_text: Option<&str>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn signal_turn_interrupt(
&self,
_session_id: &SessionId,
_response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn signal_turn_completed(
&self,
_session_id: &SessionId,
_stop_reason: StopReason,
_usage: Usage,
_response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn signal_terminal_error(
&self,
_session_id: &SessionId,
_code: LiveAdapterErrorCode,
_message: &str,
) -> Result<(), LiveProjectionError> {
Ok(())
}
async fn append_realtime_transcript(
&self,
_session_id: &SessionId,
_event: &RealtimeTranscriptEvent,
) -> Result<(), LiveProjectionError> {
Ok(())
}
}
const CLOSED_CHANNEL_TTL: std::time::Duration = std::time::Duration::from_secs(60);
struct ChannelState {
session_id: SessionId,
status: LiveAdapterStatus,
snapshot_version: u64,
adapter: Option<Arc<dyn LiveAdapter>>,
retire_at: Option<std::time::Instant>,
bound_llm_identity: Option<SessionLlmIdentity>,
pending_synthetic_obs: Option<LiveAdapterObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum LiveAdapterHostError {
#[error("channel {0} not found")]
ChannelNotFound(LiveChannelId),
#[error("session {0} not found")]
SessionNotFound(SessionId),
#[error("channel {0} is not ready (status: {1:?})")]
ChannelNotReady(LiveChannelId, LiveAdapterStatus),
#[error("session {0} already has an active channel")]
SessionAlreadyBound(SessionId),
#[error("no adapter attached to channel {0}")]
NoAdapter(LiveChannelId),
#[error(transparent)]
AdapterError(#[from] LiveAdapterError),
#[error("projection sink error: {0}")]
ProjectionError(#[from] LiveProjectionError),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ObservationRouting {
AppendTranscript,
AppendRealtimeTranscript {
event: RealtimeTranscriptEvent,
},
DispatchToolCall {
provider_call_id: String,
tool_name: String,
},
SignalInterrupt,
UpdateStatus(LiveAdapterStatus),
TerminalError,
CommandRejection,
Noop,
}
#[async_trait::async_trait]
pub trait LiveToolDispatcher: Send + Sync {
async fn dispatch_live_tool_call(
&self,
session_id: &SessionId,
call: ToolCall,
) -> Result<ToolDispatchOutcome, LiveToolDispatchError>;
}
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum LiveToolDispatchError {
#[error(transparent)]
Tool(#[from] ToolError),
#[error("live tool dispatch rejected: {0}")]
Rejected(String),
#[error("live tool dispatch internal error: {0}")]
Internal(String),
}
struct AgentLiveToolDispatcher {
inner: Arc<dyn AgentToolDispatcher>,
}
impl AgentLiveToolDispatcher {
fn new(inner: Arc<dyn AgentToolDispatcher>) -> Self {
Self { inner }
}
}
#[async_trait::async_trait]
impl LiveToolDispatcher for AgentLiveToolDispatcher {
async fn dispatch_live_tool_call(
&self,
_session_id: &SessionId,
call: ToolCall,
) -> Result<ToolDispatchOutcome, LiveToolDispatchError> {
let args_string = serde_json::to_string(&call.args).map_err(|err| {
LiveToolDispatchError::Internal(format!(
"failed to serialize live tool-call arguments: {err}"
))
})?;
let raw = RawValue::from_string(args_string).map_err(|err| {
LiveToolDispatchError::Internal(format!(
"failed to create live tool-call argument payload: {err}"
))
})?;
let view = meerkat_core::types::ToolCallView {
id: &call.id,
name: &call.name,
args: raw.as_ref(),
};
self.inner.dispatch(view).await.map_err(Into::into)
}
}
pub struct LiveAdapterHost {
inner: Mutex<HostInner>,
projection_sink: Arc<dyn LiveProjectionSink>,
tool_dispatcher: std::sync::Mutex<Option<Arc<dyn LiveToolDispatcher>>>,
tool_timeout: Option<Duration>,
}
pub const DEFAULT_LIVE_TOOL_TIMEOUT: Duration = Duration::from_secs(30);
struct HostInner {
channels: IndexMap<LiveChannelId, ChannelState>,
by_session: HashMap<SessionId, LiveChannelId>,
}
impl LiveAdapterHost {
#[must_use]
pub fn new(projection_sink: Arc<dyn LiveProjectionSink>) -> Self {
Self {
inner: Mutex::new(HostInner {
channels: IndexMap::new(),
by_session: HashMap::new(),
}),
projection_sink,
tool_dispatcher: std::sync::Mutex::new(None),
tool_timeout: None,
}
}
#[must_use]
pub fn with_tool_timeout(mut self, timeout: Duration) -> Self {
self.tool_timeout = Some(timeout);
self
}
#[must_use]
pub fn tool_timeout(&self) -> Option<Duration> {
self.tool_timeout
}
#[must_use]
pub fn with_tool_dispatcher(self, dispatcher: Arc<dyn AgentToolDispatcher>) -> Self {
self.set_tool_dispatcher(dispatcher);
self
}
#[must_use]
pub fn with_live_tool_dispatcher(self, dispatcher: Arc<dyn LiveToolDispatcher>) -> Self {
self.set_live_tool_dispatcher(dispatcher);
self
}
pub fn set_tool_dispatcher(&self, dispatcher: Arc<dyn AgentToolDispatcher>) {
self.set_live_tool_dispatcher(Arc::new(AgentLiveToolDispatcher::new(dispatcher)));
}
pub fn set_live_tool_dispatcher(&self, dispatcher: Arc<dyn LiveToolDispatcher>) {
if let Ok(mut slot) = self.tool_dispatcher.lock() {
*slot = Some(dispatcher);
}
}
fn load_dispatcher(&self) -> Option<Arc<dyn LiveToolDispatcher>> {
self.tool_dispatcher
.lock()
.ok()
.and_then(|slot| slot.as_ref().map(Arc::clone))
}
pub async fn open_channel(
&self,
session_id: SessionId,
) -> Result<LiveChannelId, LiveAdapterHostError> {
let mut inner = self.inner.lock().await;
Self::reap_retired_locked(&mut inner);
if let Some(existing) = inner.by_session.get(&session_id).cloned()
&& let Some(channel) = inner.channels.get(&existing)
&& channel.retire_at.is_none()
{
return Err(LiveAdapterHostError::SessionAlreadyBound(session_id));
}
let channel_id = LiveChannelId::random_uuid();
inner.channels.insert(
channel_id.clone(),
ChannelState {
session_id: session_id.clone(),
status: LiveAdapterStatus::Opening,
snapshot_version: 0,
adapter: None,
retire_at: None,
bound_llm_identity: None,
pending_synthetic_obs: None,
},
);
inner.by_session.insert(session_id, channel_id.clone());
Ok(channel_id)
}
pub async fn attach_adapter(
&self,
channel_id: &LiveChannelId,
adapter: Arc<dyn LiveAdapter>,
) -> Result<(), LiveAdapterHostError> {
let mut inner = self.inner.lock().await;
let channel = inner
.channels
.get_mut(channel_id)
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
if channel.retire_at.is_some() {
return Err(LiveAdapterHostError::ChannelNotFound(channel_id.clone()));
}
channel.adapter = Some(adapter);
Ok(())
}
pub async fn set_channel_llm_identity(
&self,
channel_id: &LiveChannelId,
identity: SessionLlmIdentity,
) -> Result<(), LiveAdapterHostError> {
let mut inner = self.inner.lock().await;
let channel = inner
.channels
.get_mut(channel_id)
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
channel.bound_llm_identity = Some(identity);
Ok(())
}
pub async fn channel_llm_identity(
&self,
channel_id: &LiveChannelId,
) -> Result<Option<SessionLlmIdentity>, LiveAdapterHostError> {
let inner = self.inner.lock().await;
inner
.channels
.get(channel_id)
.map(|ch| ch.bound_llm_identity.clone())
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
}
pub async fn send_command(
&self,
channel_id: &LiveChannelId,
command: LiveAdapterCommand,
) -> Result<(), LiveAdapterHostError> {
let adapter = self
.adapter_for(channel_id, false)
.await?;
adapter.send_command(command).await?;
Ok(())
}
pub async fn send_input(
&self,
channel_id: &LiveChannelId,
chunk: LiveInputChunk,
) -> Result<(), LiveAdapterHostError> {
let adapter = self
.adapter_for(channel_id, true)
.await?;
adapter
.send_command(LiveAdapterCommand::SendInput { chunk })
.await?;
Ok(())
}
pub async fn submit_tool_result(
&self,
channel_id: &LiveChannelId,
result: LiveToolResult,
) -> Result<(), LiveAdapterHostError> {
self.send_command(channel_id, LiveAdapterCommand::SubmitToolResult { result })
.await
}
pub async fn submit_tool_error(
&self,
channel_id: &LiveChannelId,
call_id: String,
error: String,
) -> Result<(), LiveAdapterHostError> {
self.send_command(
channel_id,
LiveAdapterCommand::SubmitToolError { call_id, error },
)
.await
}
pub async fn next_observation(
&self,
channel_id: &LiveChannelId,
) -> Result<Option<LiveAdapterObservation>, LiveAdapterHostError> {
let obs = self.next_observation_raw(channel_id).await?;
if let Some(ref obs) = obs {
let _ = self.apply_observation(channel_id, obs).await?;
}
Ok(obs)
}
pub async fn next_observation_raw(
&self,
channel_id: &LiveChannelId,
) -> Result<Option<LiveAdapterObservation>, LiveAdapterHostError> {
{
let mut inner = self.inner.lock().await;
if let Some(channel) = inner.channels.get_mut(channel_id)
&& let Some(obs) = channel.pending_synthetic_obs.take()
{
return Ok(Some(obs));
}
}
let adapter = self
.adapter_for(channel_id, false)
.await?;
match adapter.next_observation().await {
Ok(Some(obs)) => Ok(Some(obs)),
Ok(None) => {
let mut inner = self.inner.lock().await;
if let Some(channel) = inner.channels.get_mut(channel_id)
&& let Some(obs) = channel.pending_synthetic_obs.take()
{
return Ok(Some(obs));
}
Ok(None)
}
Err(err) => {
let synthetic = LiveAdapterObservation::Error {
code: LiveAdapterErrorCode::ProviderError,
message: format!("adapter read failure: {err}"),
};
{
let mut inner = self.inner.lock().await;
if let Some(channel) = inner.channels.get_mut(channel_id) {
channel.status = LiveAdapterStatus::Closed;
channel.retire_at = Some(std::time::Instant::now() + CLOSED_CHANNEL_TTL);
channel.adapter = None;
}
}
Ok(Some(synthetic))
}
}
}
pub async fn apply_observation(
&self,
channel_id: &LiveChannelId,
observation: &LiveAdapterObservation,
) -> Result<ObservationOutcome, LiveAdapterHostError> {
let routing = Self::classify_observation(observation);
if let ObservationRouting::UpdateStatus(ref status) = routing {
self.apply_status_update(channel_id, status.clone()).await?;
}
let session_id = self.channel_session(channel_id).await?;
match (routing, observation) {
(ObservationRouting::Noop, _) => Ok(ObservationOutcome::Noop),
(ObservationRouting::UpdateStatus(status), _) => {
Ok(ObservationOutcome::StatusUpdated(status))
}
(
ObservationRouting::AppendTranscript,
LiveAdapterObservation::UserTranscriptFinal {
provider_item_id,
previous_item_id,
content_index,
text,
..
},
) => {
let identity = LiveTranscriptIdentity::user(
provider_item_id.as_deref(),
previous_item_id.as_deref(),
*content_index,
);
self.projection_sink
.append_user_transcript(&session_id, text, identity)
.await?;
Ok(ObservationOutcome::TranscriptAppended)
}
(
ObservationRouting::AppendTranscript,
LiveAdapterObservation::AssistantTextDelta {
provider_item_id,
previous_item_id,
content_index,
response_id,
delta_id,
delta,
..
},
) => {
let identity = LiveTranscriptIdentity::assistant_delta(
provider_item_id.as_deref(),
previous_item_id.as_deref(),
*content_index,
response_id.as_deref(),
delta_id.as_deref(),
);
self.projection_sink
.append_assistant_text_delta(&session_id, delta, identity)
.await?;
Ok(ObservationOutcome::TranscriptAppended)
}
(
ObservationRouting::AppendTranscript,
LiveAdapterObservation::AssistantTranscriptDelta {
provider_item_id,
previous_item_id,
content_index,
response_id,
delta_id,
delta,
..
},
) => {
let identity = LiveTranscriptIdentity::assistant_delta(
provider_item_id.as_deref(),
previous_item_id.as_deref(),
*content_index,
response_id.as_deref(),
delta_id.as_deref(),
);
self.projection_sink
.append_assistant_transcript_delta(&session_id, delta, identity)
.await?;
Ok(ObservationOutcome::TranscriptAppended)
}
(
ObservationRouting::AppendTranscript,
LiveAdapterObservation::AssistantTranscriptFinal {
provider_item_id,
previous_item_id,
content_index,
response_id,
text,
stop_reason,
usage,
..
},
) => {
let identity = LiveTranscriptIdentity::assistant_final(
provider_item_id,
previous_item_id.as_deref(),
*content_index,
response_id.as_deref(),
);
self.projection_sink
.append_assistant_transcript_final(
&session_id,
text,
identity,
*stop_reason,
usage.clone(),
response_id.as_deref(),
)
.await?;
Ok(ObservationOutcome::TranscriptAppended)
}
(
ObservationRouting::AppendTranscript,
LiveAdapterObservation::AssistantTranscriptTruncated {
provider_item_id,
previous_item_id,
content_index,
response_id,
text,
},
) => {
self.projection_sink
.truncate_assistant_transcript(
&session_id,
provider_item_id.as_deref(),
previous_item_id.as_deref(),
*content_index,
response_id.as_deref(),
text.as_deref(),
)
.await?;
Ok(ObservationOutcome::TranscriptTruncated)
}
(
ObservationRouting::AppendTranscript,
LiveAdapterObservation::TurnCompleted {
response_id,
stop_reason,
usage,
},
) => {
self.projection_sink
.signal_turn_completed(
&session_id,
*stop_reason,
usage.clone(),
response_id.as_deref(),
)
.await?;
Ok(ObservationOutcome::TranscriptAppended)
}
(ObservationRouting::AppendRealtimeTranscript { event }, _) => {
self.projection_sink
.append_realtime_transcript(&session_id, &event)
.await?;
Ok(ObservationOutcome::TranscriptAppended)
}
(
ObservationRouting::DispatchToolCall { .. },
LiveAdapterObservation::ToolCallRequested {
provider_call_id,
tool_name,
arguments,
},
) => {
self.dispatch_tool_call(channel_id, provider_call_id, tool_name, arguments.clone())
.await
}
(
ObservationRouting::SignalInterrupt,
LiveAdapterObservation::TurnInterrupted { response_id },
) => {
self.projection_sink
.signal_turn_interrupt(&session_id, response_id.as_deref())
.await?;
Ok(ObservationOutcome::InterruptSignalled)
}
(
ObservationRouting::TerminalError,
LiveAdapterObservation::Error { code, message },
) => {
{
let mut inner = self.inner.lock().await;
if let Some(channel) = inner.channels.get_mut(channel_id) {
channel.status = LiveAdapterStatus::Closed;
channel.retire_at = Some(std::time::Instant::now() + CLOSED_CHANNEL_TTL);
}
}
self.projection_sink
.signal_terminal_error(&session_id, code.clone(), message)
.await?;
Ok(ObservationOutcome::Terminal { code: code.clone() })
}
(
ObservationRouting::CommandRejection,
LiveAdapterObservation::CommandRejected { code, message },
) => Ok(ObservationOutcome::CommandRejected {
code: code.clone(),
message: message.clone(),
}),
(ObservationRouting::AppendTranscript, _) => Ok(ObservationOutcome::Noop),
_ => Ok(ObservationOutcome::Noop),
}
}
async fn dispatch_tool_call(
&self,
channel_id: &LiveChannelId,
provider_call_id: &str,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ObservationOutcome, LiveAdapterHostError> {
let dispatcher = match self.load_dispatcher() {
Some(d) => d,
None => {
let _ = self
.submit_tool_error(
channel_id,
provider_call_id.to_string(),
"live tool dispatcher not configured".to_string(),
)
.await;
return Ok(ObservationOutcome::ToolCallSkipped {
provider_call_id: provider_call_id.to_string(),
tool_name: tool_name.to_string(),
reason: ToolDispatchSkipReason::NoDispatcher,
});
}
};
let session_id = self.channel_session(channel_id).await?;
let call = ToolCall::new(
provider_call_id.to_string(),
tool_name.to_string(),
arguments,
);
let dispatch_call = dispatcher.dispatch_live_tool_call(&session_id, call);
let dispatch_result = match self.tool_timeout {
Some(timeout) => match tokio::time::timeout(timeout, dispatch_call).await {
Ok(result) => result,
Err(_elapsed) => {
let error_text = format!("tool dispatch timeout after {timeout:?}");
self.submit_tool_error(channel_id, provider_call_id.to_string(), error_text)
.await?;
return Ok(ObservationOutcome::ToolCallTimedOut {
provider_call_id: provider_call_id.to_string(),
tool_name: tool_name.to_string(),
timeout,
});
}
},
None => dispatch_call.await,
};
match dispatch_result {
Ok(outcome) => {
let live_result =
tool_result_from_dispatch(provider_call_id.to_string(), outcome.result);
self.submit_tool_result(channel_id, live_result).await?;
}
Err(err) => {
self.submit_tool_error(channel_id, provider_call_id.to_string(), err.to_string())
.await?;
}
}
Ok(ObservationOutcome::ToolCallDispatched {
provider_call_id: provider_call_id.to_string(),
tool_name: tool_name.to_string(),
})
}
async fn adapter_for(
&self,
channel_id: &LiveChannelId,
require_ready: bool,
) -> Result<Arc<dyn LiveAdapter>, LiveAdapterHostError> {
let inner = self.inner.lock().await;
let channel = inner
.channels
.get(channel_id)
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
if channel.retire_at.is_some() {
return Err(LiveAdapterHostError::ChannelNotReady(
channel_id.clone(),
channel.status.clone(),
));
}
if require_ready && !channel.status.accepts_commands() {
return Err(LiveAdapterHostError::ChannelNotReady(
channel_id.clone(),
channel.status.clone(),
));
}
let adapter = channel
.adapter
.as_ref()
.ok_or_else(|| LiveAdapterHostError::NoAdapter(channel_id.clone()))?;
Ok(Arc::clone(adapter))
}
pub async fn signal_terminal_error(
&self,
channel_id: &LiveChannelId,
code: LiveAdapterErrorCode,
) -> Result<(), LiveAdapterHostError> {
let message = match &code {
LiveAdapterErrorCode::ConfigRejected { reason } => reason.to_string(),
other => format!("{other:?}"),
};
let synthetic = LiveAdapterObservation::Error {
code: code.clone(),
message: message.clone(),
};
let adapter = {
let mut inner = self.inner.lock().await;
let channel = inner
.channels
.get_mut(channel_id)
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
channel.pending_synthetic_obs = Some(synthetic.clone());
channel.adapter.clone()
};
if let Some(adapter) = adapter {
let _ = adapter.inject_observation(synthetic).await;
}
self.close_channel(channel_id).await
}
pub async fn close_channel(
&self,
channel_id: &LiveChannelId,
) -> Result<(), LiveAdapterHostError> {
let adapter = {
let mut inner = self.inner.lock().await;
Self::reap_retired_locked(&mut inner);
let channel = inner
.channels
.get_mut(channel_id)
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
let adapter = channel.adapter.take();
channel.status = LiveAdapterStatus::Closed;
channel.retire_at = Some(std::time::Instant::now() + CLOSED_CHANNEL_TTL);
adapter
};
if let Some(adapter) = adapter {
let _ = adapter.close().await;
}
Ok(())
}
pub async fn channel_status(
&self,
channel_id: &LiveChannelId,
) -> Result<LiveAdapterStatus, LiveAdapterHostError> {
let mut inner = self.inner.lock().await;
Self::reap_retired_locked(&mut inner);
inner
.channels
.get(channel_id)
.map(|ch| ch.status.clone())
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
}
pub async fn channel_session(
&self,
channel_id: &LiveChannelId,
) -> Result<SessionId, LiveAdapterHostError> {
let inner = self.inner.lock().await;
inner
.channels
.get(channel_id)
.map(|ch| ch.session_id.clone())
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))
}
pub fn classify_observation(observation: &LiveAdapterObservation) -> ObservationRouting {
match observation {
LiveAdapterObservation::Ready => {
ObservationRouting::UpdateStatus(LiveAdapterStatus::Ready)
}
LiveAdapterObservation::UserTranscriptFinal { .. } => {
ObservationRouting::AppendTranscript
}
LiveAdapterObservation::AssistantTextDelta { .. } => {
ObservationRouting::AppendTranscript
}
LiveAdapterObservation::AssistantTranscriptDelta { .. } => {
ObservationRouting::AppendTranscript
}
LiveAdapterObservation::AssistantAudioChunk { .. } => ObservationRouting::Noop,
LiveAdapterObservation::AssistantTranscriptFinal { .. } => {
ObservationRouting::AppendTranscript
}
LiveAdapterObservation::AssistantTranscriptTruncated { .. } => {
ObservationRouting::AppendTranscript
}
LiveAdapterObservation::RealtimeTranscript { event } => {
ObservationRouting::AppendRealtimeTranscript {
event: event.clone(),
}
}
LiveAdapterObservation::ToolCallRequested {
provider_call_id,
tool_name,
..
} => ObservationRouting::DispatchToolCall {
provider_call_id: provider_call_id.clone(),
tool_name: tool_name.clone(),
},
LiveAdapterObservation::TurnInterrupted { .. } => ObservationRouting::SignalInterrupt,
LiveAdapterObservation::TurnCompleted { .. } => ObservationRouting::AppendTranscript,
LiveAdapterObservation::StatusChanged { status } => {
ObservationRouting::UpdateStatus(status.clone())
}
LiveAdapterObservation::Error { .. } => ObservationRouting::TerminalError,
LiveAdapterObservation::CommandRejected { .. } => ObservationRouting::CommandRejection,
_ => ObservationRouting::Noop,
}
}
pub async fn apply_status_update(
&self,
channel_id: &LiveChannelId,
status: LiveAdapterStatus,
) -> Result<(), LiveAdapterHostError> {
let mut inner = self.inner.lock().await;
let channel = inner
.channels
.get_mut(channel_id)
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
channel.status = status;
Ok(())
}
pub async fn next_snapshot_version(
&self,
channel_id: &LiveChannelId,
) -> Result<u64, LiveAdapterHostError> {
let mut inner = self.inner.lock().await;
let channel = inner
.channels
.get_mut(channel_id)
.ok_or_else(|| LiveAdapterHostError::ChannelNotFound(channel_id.clone()))?;
channel.snapshot_version += 1;
Ok(channel.snapshot_version)
}
pub async fn active_channels(&self) -> Vec<LiveChannelId> {
let mut inner = self.inner.lock().await;
Self::reap_retired_locked(&mut inner);
inner
.channels
.iter()
.filter(|(_, ch)| ch.retire_at.is_none())
.map(|(id, _)| id.clone())
.collect()
}
fn reap_retired_locked(inner: &mut HostInner) {
let now = std::time::Instant::now();
let to_drop: Vec<LiveChannelId> = inner
.channels
.iter()
.filter_map(|(id, ch)| match ch.retire_at {
Some(deadline) if deadline <= now => Some(id.clone()),
_ => None,
})
.collect();
for id in to_drop {
if let Some(ch) = inner.channels.shift_remove(&id) {
if inner
.by_session
.get(&ch.session_id)
.is_some_and(|current| current == &id)
{
inner.by_session.remove(&ch.session_id);
}
}
}
}
}
fn tool_result_from_dispatch(call_id: String, result: ToolResult) -> LiveToolResult {
LiveToolResult {
call_id,
content: result.content,
is_error: result.is_error,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use async_trait::async_trait;
use meerkat_core::live_adapter::{
LiveAdapterError, LiveAdapterErrorCode, LiveAdapterObservation, LiveDegradationReason,
};
use meerkat_core::ops::ToolDispatchOutcome;
use meerkat_core::types::{StopReason, ToolDef, Usage};
use meerkat_core::{DispatcherCapabilities, ToolCatalogCapabilities, ToolCatalogEntry};
use std::sync::Mutex as StdMutex;
fn test_session_id() -> SessionId {
SessionId::new()
}
#[test]
fn tool_timeout_defaults_to_none_and_builder_sets_it() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
assert_eq!(host.tool_timeout(), None);
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink))
.with_tool_timeout(DEFAULT_LIVE_TOOL_TIMEOUT);
assert_eq!(host.tool_timeout(), Some(DEFAULT_LIVE_TOOL_TIMEOUT));
assert_eq!(DEFAULT_LIVE_TOOL_TIMEOUT, Duration::from_secs(30));
}
#[tokio::test]
async fn projection_sink_is_mandatory_at_construction() {
let recording = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&recording) as _);
let session = test_session_id();
let ch = host.open_channel(session.clone()).await.unwrap();
let obs = LiveAdapterObservation::UserTranscriptFinal {
provider_item_id: Some("item-1".into()),
previous_item_id: None,
content_index: Some(0),
text: "hello".into(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
assert_eq!(
recording.user_transcripts.lock().unwrap().len(),
1,
"production-shape host must route user transcripts to the sink"
);
let noop_host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let session2 = test_session_id();
let ch2 = noop_host.open_channel(session2).await.unwrap();
let outcome2 = noop_host.apply_observation(&ch2, &obs).await.unwrap();
assert!(matches!(outcome2, ObservationOutcome::TranscriptAppended));
}
#[tokio::test]
async fn open_channel_returns_unique_ids() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let s1 = test_session_id();
let s2 = test_session_id();
let ch1 = host.open_channel(s1).await.unwrap();
let ch2 = host.open_channel(s2).await.unwrap();
assert_ne!(ch1, ch2);
}
#[tokio::test]
async fn open_channel_ids_are_uuid_shape_not_live_n() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch1 = host.open_channel(test_session_id()).await.unwrap();
let ch2 = host.open_channel(test_session_id()).await.unwrap();
for ch in [&ch1, &ch2] {
let s = ch.as_str();
assert!(
!s.starts_with("live_"),
"channel id retained legacy `live_N` shape: {s}"
);
let parsed =
uuid::Uuid::parse_str(s).expect("channel id should be a valid UUID string");
assert_eq!(
parsed.get_version(),
Some(uuid::Version::Random),
"channel id should be a v4 UUID"
);
}
assert_ne!(ch1, ch2);
}
#[tokio::test]
async fn open_channel_starts_in_opening_status() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
let status = host.channel_status(&ch).await.unwrap();
assert_eq!(status, LiveAdapterStatus::Opening);
}
#[tokio::test]
async fn duplicate_session_binding_rejected() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let session_id = test_session_id();
let _ch = host.open_channel(session_id.clone()).await.unwrap();
let err = host.open_channel(session_id.clone()).await.unwrap_err();
assert!(matches!(err, LiveAdapterHostError::SessionAlreadyBound(id) if id == session_id));
}
#[tokio::test]
async fn close_channel_marks_closed_and_retains_for_status_reads() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.close_channel(&ch).await.unwrap();
let status = host.channel_status(&ch).await.unwrap();
assert_eq!(status, LiveAdapterStatus::Closed);
}
#[tokio::test]
async fn close_channel_allows_rebinding_same_session() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
host.close_channel(&ch).await.unwrap();
let ch2 = host.open_channel(session_id).await.unwrap();
assert_ne!(ch, ch2);
}
#[tokio::test]
async fn reap_of_retired_channel_preserves_rebound_session_mapping() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let session_id = test_session_id();
let ch_a = host.open_channel(session_id.clone()).await.unwrap();
host.close_channel(&ch_a).await.unwrap();
let ch_b = host.open_channel(session_id.clone()).await.unwrap();
assert_ne!(ch_a, ch_b);
{
let mut inner = host.inner.lock().await;
if let Some(channel) = inner.channels.get_mut(&ch_a) {
channel.retire_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
}
}
let active = host.active_channels().await;
assert_eq!(active, vec![ch_b.clone()]);
{
let inner = host.inner.lock().await;
assert_eq!(
inner.by_session.get(&session_id),
Some(&ch_b),
"reap of retired A must not clear B's reverse mapping"
);
assert!(
!inner.channels.contains_key(&ch_a),
"retired channel A must be dropped"
);
assert!(
inner.channels.contains_key(&ch_b),
"rebound channel B must remain"
);
}
let err = host.open_channel(session_id.clone()).await.unwrap_err();
assert!(
matches!(err, LiveAdapterHostError::SessionAlreadyBound(id) if id == session_id),
"after reap, third open for session must still see B as bound"
);
assert_eq!(host.active_channels().await.len(), 1);
}
#[tokio::test]
async fn active_channels_excludes_retained_closed_channels() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let s1 = test_session_id();
let s2 = test_session_id();
let live = host.open_channel(s1).await.unwrap();
let closing = host.open_channel(s2).await.unwrap();
let active_pre = host.active_channels().await;
assert!(active_pre.contains(&live));
assert!(active_pre.contains(&closing));
assert_eq!(active_pre.len(), 2);
host.close_channel(&closing).await.unwrap();
let active_during_ttl = host.active_channels().await;
assert_eq!(
active_during_ttl,
vec![live.clone()],
"retained-closed channel must not appear in active_channels()"
);
assert_eq!(
host.channel_status(&closing).await.unwrap(),
LiveAdapterStatus::Closed,
);
{
let mut inner = host.inner.lock().await;
if let Some(channel) = inner.channels.get_mut(&closing) {
channel.retire_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
}
}
let active_post_reap = host.active_channels().await;
assert_eq!(active_post_reap, vec![live.clone()]);
{
let inner = host.inner.lock().await;
assert!(
!inner.channels.contains_key(&closing),
"post-reap, retired channel must be dropped from the map"
);
}
}
#[tokio::test]
async fn channel_session_returns_bound_session() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
assert_eq!(host.channel_session(&ch).await.unwrap(), session_id);
}
#[tokio::test]
async fn signal_terminal_error_enqueues_synthetic_error_obs_and_closes_channel() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
.await
.unwrap();
let code = LiveAdapterErrorCode::ConfigRejected {
reason: meerkat_core::live_adapter::LiveConfigRejectionReason::RefreshModelSwap {
from_model: "gpt-realtime".to_string(),
to_model: "gpt-realtime-1.5".to_string(),
},
};
host.signal_terminal_error(&ch, code).await.unwrap();
let status = host.channel_status(&ch).await.unwrap();
assert_eq!(status, LiveAdapterStatus::Closed);
let obs = host
.next_observation_raw(&ch)
.await
.expect("next_observation_raw should return synthetic obs even post-close")
.expect("synthetic obs must be Some");
match obs {
LiveAdapterObservation::Error { code, message } => match code {
LiveAdapterErrorCode::ConfigRejected { reason } => {
assert!(matches!(
reason,
meerkat_core::live_adapter::LiveConfigRejectionReason::RefreshModelSwap {
ref to_model,
..
} if to_model == "gpt-realtime-1.5"
));
assert!(message.contains("close + reopen"));
}
other => panic!("expected ConfigRejected, got {other:?}"),
},
other => panic!("expected Error observation, got {other:?}"),
}
}
#[tokio::test]
async fn synthetic_terminal_error_routes_through_apply_observation_to_terminal_outcome() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
.await
.unwrap();
let code = LiveAdapterErrorCode::ConfigRejected {
reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ChannelIdentitySwap {
from_model: "a".to_string(),
from_provider: meerkat_core::Provider::OpenAI,
to_model: "b".to_string(),
to_provider: meerkat_core::Provider::OpenAI,
},
};
host.signal_terminal_error(&ch, code).await.unwrap();
let obs = host.next_observation_raw(&ch).await.unwrap().unwrap();
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
match outcome {
ObservationOutcome::Terminal { code } => match code {
LiveAdapterErrorCode::ConfigRejected { reason } => {
assert!(matches!(
reason,
meerkat_core::live_adapter::LiveConfigRejectionReason::ChannelIdentitySwap {
ref from_model, ref to_model, ..
} if from_model == "a" && to_model == "b"
));
}
other => panic!("expected ConfigRejected, got {other:?}"),
},
other => panic!("expected Terminal outcome, got {other:?}"),
}
}
#[tokio::test]
async fn signal_terminal_error_delivers_synthetic_error_before_close_signal() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
.await
.unwrap();
let code = LiveAdapterErrorCode::ConfigRejected {
reason: meerkat_core::live_adapter::LiveConfigRejectionReason::Other {
detail: "model_swap_test".to_string(),
},
};
host.signal_terminal_error(&ch, code).await.unwrap();
let first = host
.next_observation_raw(&ch)
.await
.expect("first read should succeed")
.expect("synthetic Error must surface before end-of-stream");
match first {
LiveAdapterObservation::Error { code, message } => match code {
LiveAdapterErrorCode::ConfigRejected { reason } => {
assert!(matches!(
&reason,
meerkat_core::live_adapter::LiveConfigRejectionReason::Other { detail }
if detail == "model_swap_test"
));
assert_eq!(message, "model_swap_test");
}
other => unreachable!("expected ConfigRejected, got {other:?}"),
},
other => unreachable!("expected Error obs first, got {other:?}"),
}
}
#[tokio::test]
async fn signal_terminal_error_on_missing_channel_returns_channel_not_found() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let bogus = LiveChannelId::random_uuid();
let result = host
.signal_terminal_error(
&bogus,
LiveAdapterErrorCode::ConfigRejected {
reason: meerkat_core::live_adapter::LiveConfigRejectionReason::Other {
detail: "no channel".into(),
},
},
)
.await;
assert!(
matches!(&result, Err(LiveAdapterHostError::ChannelNotFound(id)) if id == &bogus),
"expected ChannelNotFound for unknown channel, got {result:?}"
);
}
#[test]
fn ready_observation_routes_to_status_update() {
let routing = LiveAdapterHost::classify_observation(&LiveAdapterObservation::Ready);
assert_eq!(
routing,
ObservationRouting::UpdateStatus(LiveAdapterStatus::Ready)
);
}
#[test]
fn tool_call_observation_routes_to_dispatch() {
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_1".into(),
tool_name: "calculator".into(),
arguments: serde_json::json!({"x": 1}),
};
let routing = LiveAdapterHost::classify_observation(&obs);
assert_eq!(
routing,
ObservationRouting::DispatchToolCall {
provider_call_id: "call_1".into(),
tool_name: "calculator".into(),
}
);
}
#[test]
fn barge_in_observation_routes_to_interrupt() {
let routing =
LiveAdapterHost::classify_observation(&LiveAdapterObservation::TurnInterrupted {
response_id: None,
});
assert_eq!(routing, ObservationRouting::SignalInterrupt);
let routing_with_id =
LiveAdapterHost::classify_observation(&LiveAdapterObservation::TurnInterrupted {
response_id: Some("resp_42".into()),
});
assert_eq!(routing_with_id, ObservationRouting::SignalInterrupt);
}
#[test]
fn user_transcript_routes_to_append() {
let obs = LiveAdapterObservation::UserTranscriptFinal {
provider_item_id: Some("item_1".into()),
previous_item_id: None,
content_index: None,
text: "hello".into(),
};
assert_eq!(
LiveAdapterHost::classify_observation(&obs),
ObservationRouting::AppendTranscript
);
}
#[test]
fn assistant_text_delta_routes_to_append() {
let obs = LiveAdapterObservation::AssistantTextDelta {
provider_item_id: Some("item_2".into()),
previous_item_id: None,
content_index: None,
response_id: None,
delta_id: None,
delta: "world".into(),
};
assert_eq!(
LiveAdapterHost::classify_observation(&obs),
ObservationRouting::AppendTranscript
);
}
#[test]
fn turn_completed_routes_to_append() {
let obs = LiveAdapterObservation::TurnCompleted {
response_id: None,
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
cache_creation_tokens: None,
cache_read_tokens: None,
},
};
assert_eq!(
LiveAdapterHost::classify_observation(&obs),
ObservationRouting::AppendTranscript
);
}
#[test]
fn error_observation_routes_to_terminal() {
let obs = LiveAdapterObservation::Error {
code: LiveAdapterErrorCode::ConnectionLost,
message: "ws closed".into(),
};
assert_eq!(
LiveAdapterHost::classify_observation(&obs),
ObservationRouting::TerminalError
);
}
#[test]
fn audio_chunk_routes_to_noop() {
let obs = LiveAdapterObservation::AssistantAudioChunk {
data: vec![0; 100],
sample_rate_hz: 24000,
channels: 1,
response_id: None,
item_id: None,
content_index: None,
};
assert_eq!(
LiveAdapterHost::classify_observation(&obs),
ObservationRouting::Noop
);
}
#[test]
fn status_changed_routes_to_status_update() {
let obs = LiveAdapterObservation::StatusChanged {
status: LiveAdapterStatus::Degraded {
reason: LiveDegradationReason::ProviderThrottled,
},
};
assert_eq!(
LiveAdapterHost::classify_observation(&obs),
ObservationRouting::UpdateStatus(LiveAdapterStatus::Degraded {
reason: LiveDegradationReason::ProviderThrottled,
})
);
}
#[tokio::test]
async fn apply_status_update_changes_channel_status() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
assert_eq!(
host.channel_status(&ch).await.unwrap(),
LiveAdapterStatus::Ready
);
}
#[tokio::test]
async fn snapshot_version_increments_monotonically() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
let v1 = host.next_snapshot_version(&ch).await.unwrap();
let v2 = host.next_snapshot_version(&ch).await.unwrap();
assert_eq!(v1, 1);
assert_eq!(v2, 2);
}
#[tokio::test]
async fn active_channels_lists_open_channels() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch1 = host.open_channel(test_session_id()).await.unwrap();
let ch2 = host.open_channel(test_session_id()).await.unwrap();
let active = host.active_channels().await;
assert_eq!(active.len(), 2);
assert!(active.contains(&ch1));
assert!(active.contains(&ch2));
}
#[tokio::test]
async fn send_input_without_adapter_returns_error() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
let err = host
.send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
.await
.unwrap_err();
assert!(matches!(err, LiveAdapterHostError::ChannelNotReady(_, _)));
}
#[tokio::test]
async fn attach_adapter_does_not_assert_ready() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
assert_eq!(
host.channel_status(&ch).await.unwrap(),
LiveAdapterStatus::Opening
);
host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
.await
.unwrap();
assert_eq!(
host.channel_status(&ch).await.unwrap(),
LiveAdapterStatus::Opening,
"attach_adapter must NOT mark channel Ready (F32)"
);
}
#[tokio::test]
async fn send_input_rejected_when_not_ready() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
.await
.unwrap();
let err = host
.send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
.await
.unwrap_err();
match err {
LiveAdapterHostError::ChannelNotReady(_, status) => {
assert_eq!(status, LiveAdapterStatus::Opening);
}
other => panic!("expected ChannelNotReady, got {other:?}"),
}
}
#[tokio::test]
async fn send_input_accepts_when_ready() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
host.send_input(&ch, LiveInputChunk::Text { text: "hi".into() })
.await
.unwrap();
}
#[tokio::test]
async fn adapter_pump_error_terminalizes_channel_status() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::new(ErroringAdapter))
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = host
.next_observation_raw(&ch)
.await
.unwrap()
.expect("synthetic Error obs surfaces on adapter Err");
match obs {
LiveAdapterObservation::Error { code, message } => {
assert_eq!(code, LiveAdapterErrorCode::ProviderError);
assert!(
message.contains("adapter read failure"),
"synthetic message must explain origin; got `{message}`"
);
}
other => unreachable!("expected synthetic Error, got {other:?}"),
}
let status = host.channel_status(&ch).await.unwrap();
assert_eq!(status, LiveAdapterStatus::Closed);
{
let inner = host.inner.lock().await;
let channel = inner
.channels
.get(&ch)
.expect("channel preserved for live/status until TTL elapses");
assert!(
channel.retire_at.is_some(),
"R5-8: adapter Err must set retire_at"
);
assert!(
channel.adapter.is_none(),
"R5-8: adapter Err must drop the adapter Arc"
);
}
}
#[tokio::test]
async fn command_rejected_routes_non_terminally_and_preserves_channel() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::new(StubAdapter::new()))
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::CommandRejected {
code: LiveAdapterErrorCode::ConfigRejected {
reason:
meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputNotImplemented,
},
message: "image_input_not_implemented".into(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
match outcome {
ObservationOutcome::CommandRejected { code, message } => {
assert!(matches!(
code,
LiveAdapterErrorCode::ConfigRejected {
reason: meerkat_core::live_adapter::LiveConfigRejectionReason::ImageInputNotImplemented,
}
));
assert_eq!(message, "image_input_not_implemented");
}
other => {
unreachable!("CommandRejected must produce CommandRejected outcome, got {other:?}")
}
}
let status = host.channel_status(&ch).await.unwrap();
assert_eq!(status, LiveAdapterStatus::Ready);
{
let inner = host.inner.lock().await;
let channel = inner.channels.get(&ch).expect("channel present");
assert!(
channel.retire_at.is_none(),
"CommandRejected must not retire the channel"
);
assert!(
channel.adapter.is_some(),
"CommandRejected must not drop the adapter"
);
}
}
#[tokio::test]
async fn adapter_err_releases_session_for_rebind() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let session_id = test_session_id();
let ch1 = host.open_channel(session_id.clone()).await.unwrap();
host.attach_adapter(&ch1, Arc::new(ErroringAdapter))
.await
.unwrap();
host.apply_status_update(&ch1, LiveAdapterStatus::Ready)
.await
.unwrap();
let _ = host.next_observation_raw(&ch1).await.unwrap();
{
let mut inner = host.inner.lock().await;
if let Some(channel) = inner.channels.get_mut(&ch1) {
channel.retire_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
}
}
let ch2 = host
.open_channel(session_id.clone())
.await
.expect("rebind for same session must succeed once previous channel is retired");
assert_ne!(ch1, ch2);
}
#[tokio::test]
async fn user_transcript_observation_appends_to_sink() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let obs = LiveAdapterObservation::UserTranscriptFinal {
provider_item_id: Some("item_1".into()),
previous_item_id: None,
content_index: None,
text: "hello world".into(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
let user = sink.user_transcripts.lock().unwrap();
assert_eq!(user.len(), 1);
assert_eq!(user[0].0, session_id);
assert_eq!(user[0].1, "hello world");
assert_eq!(user[0].2.provider_item_id.as_deref(), Some("item_1"));
}
#[tokio::test]
async fn user_transcript_full_identity_propagates_end_to_end() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let obs = LiveAdapterObservation::UserTranscriptFinal {
provider_item_id: Some("item_1".into()),
previous_item_id: Some("item_0".into()),
content_index: Some(2),
text: "hello".into(),
};
host.apply_observation(&ch, &obs).await.unwrap();
let user = sink.user_transcripts.lock().unwrap();
let identity = &user[0].2;
assert_eq!(identity.provider_item_id.as_deref(), Some("item_1"));
assert_eq!(identity.previous_item_id.as_deref(), Some("item_0"));
assert_eq!(identity.content_index, Some(2));
assert_eq!(identity.response_id, None);
assert_eq!(identity.delta_id, None);
}
#[tokio::test]
async fn assistant_text_delta_observation_appends_to_sink() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let obs = LiveAdapterObservation::AssistantTextDelta {
provider_item_id: None,
previous_item_id: None,
content_index: None,
response_id: None,
delta_id: None,
delta: "Hello".into(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
let deltas = sink.text_deltas.lock().unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].0, session_id);
assert_eq!(deltas[0].1, "Hello");
assert!(sink.transcript_deltas.lock().unwrap().is_empty());
}
#[tokio::test]
async fn assistant_text_delta_full_identity_propagates_end_to_end() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
let obs = LiveAdapterObservation::AssistantTextDelta {
provider_item_id: Some("item_42".into()),
previous_item_id: Some("item_41".into()),
content_index: Some(1),
response_id: Some("resp_xyz".into()),
delta_id: Some("d_7".into()),
delta: "world".into(),
};
host.apply_observation(&ch, &obs).await.unwrap();
let deltas = sink.text_deltas.lock().unwrap();
let identity = &deltas[0].2;
assert_eq!(identity.provider_item_id.as_deref(), Some("item_42"));
assert_eq!(identity.previous_item_id.as_deref(), Some("item_41"));
assert_eq!(identity.content_index, Some(1));
assert_eq!(identity.response_id.as_deref(), Some("resp_xyz"));
assert_eq!(identity.delta_id.as_deref(), Some("d_7"));
}
#[tokio::test]
async fn assistant_transcript_final_observation_calls_sink() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let obs = LiveAdapterObservation::AssistantTranscriptFinal {
provider_item_id: "resp_1".into(),
previous_item_id: None,
content_index: None,
response_id: None,
text: "All done.".into(),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
};
host.apply_observation(&ch, &obs).await.unwrap();
let finals = sink.transcript_finals.lock().unwrap();
assert_eq!(finals.len(), 1);
assert_eq!(finals[0].0, session_id);
assert_eq!(finals[0].1, "All done.");
assert!(sink.text_finals.lock().unwrap().is_empty());
}
#[tokio::test]
async fn assistant_transcript_final_full_identity_propagates_end_to_end() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
let obs = LiveAdapterObservation::AssistantTranscriptFinal {
provider_item_id: "item_final".into(),
previous_item_id: Some("item_prev".into()),
content_index: Some(0),
response_id: Some("resp_final".into()),
text: "done".into(),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
};
host.apply_observation(&ch, &obs).await.unwrap();
let finals = sink.transcript_finals.lock().unwrap();
let identity = &finals[0].2;
assert_eq!(identity.provider_item_id.as_deref(), Some("item_final"));
assert_eq!(identity.previous_item_id.as_deref(), Some("item_prev"));
assert_eq!(identity.content_index, Some(0));
assert_eq!(identity.response_id.as_deref(), Some("resp_final"));
assert_eq!(identity.delta_id, None);
}
#[tokio::test]
async fn turn_completed_observation_signals_sink() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
let obs = LiveAdapterObservation::TurnCompleted {
response_id: None,
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
};
host.apply_observation(&ch, &obs).await.unwrap();
let turns = sink.turn_completed.lock().unwrap();
assert_eq!(turns.len(), 1);
}
#[tokio::test]
async fn assistant_transcript_delta_routes_to_transcript_lane() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let obs = LiveAdapterObservation::AssistantTranscriptDelta {
provider_item_id: Some("item_t".into()),
previous_item_id: None,
content_index: Some(0),
response_id: Some("resp_t".into()),
delta_id: Some("d_t".into()),
delta: "spoken word".into(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
let transcript_deltas = sink.transcript_deltas.lock().unwrap();
assert_eq!(transcript_deltas.len(), 1);
assert_eq!(transcript_deltas[0].0, session_id);
assert_eq!(transcript_deltas[0].1, "spoken word");
assert!(
sink.text_deltas.lock().unwrap().is_empty(),
"AssistantTranscriptDelta must not reach the text-lane sink (T6)"
);
}
#[tokio::test]
async fn realtime_transcript_observation_routes_to_append_realtime_transcript() {
use meerkat_core::RealtimeTranscriptRole;
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let event = RealtimeTranscriptEvent::ItemObserved {
item_id: "item_realtime_1".into(),
previous_item_id: Some("item_realtime_0".into()),
role: RealtimeTranscriptRole::Assistant,
response_id: Some("resp_realtime_1".into()),
};
let obs = LiveAdapterObservation::RealtimeTranscript {
event: event.clone(),
};
let routing = LiveAdapterHost::classify_observation(&obs);
match routing {
ObservationRouting::AppendRealtimeTranscript { event: routed } => {
assert_eq!(routed, event);
}
other => panic!("expected AppendRealtimeTranscript, got {other:?}"),
}
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
assert!(
matches!(outcome, ObservationOutcome::TranscriptAppended),
"expected TranscriptAppended, got {outcome:?}"
);
let recorded = sink.realtime_events.lock().unwrap();
assert_eq!(recorded.len(), 1, "sink must see exactly one append");
assert_eq!(recorded[0].0, session_id);
assert_eq!(recorded[0].1, event);
assert!(sink.text_deltas.lock().unwrap().is_empty());
assert!(sink.transcript_deltas.lock().unwrap().is_empty());
assert!(sink.text_finals.lock().unwrap().is_empty());
assert!(sink.transcript_finals.lock().unwrap().is_empty());
assert!(sink.user_transcripts.lock().unwrap().is_empty());
assert!(sink.turn_completed.lock().unwrap().is_empty());
assert!(sink.interrupts.lock().unwrap().is_empty());
}
#[tokio::test]
async fn noop_projection_sink_explicitly_accepts_realtime_transcript() {
use meerkat_core::RealtimeTranscriptRole;
let sink: Arc<dyn LiveProjectionSink> = Arc::new(NoOpProjectionSink);
let host = LiveAdapterHost::new(Arc::clone(&sink));
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let event = RealtimeTranscriptEvent::ItemObserved {
item_id: "item_noop".into(),
previous_item_id: None,
role: RealtimeTranscriptRole::Assistant,
response_id: Some("resp_noop".into()),
};
let obs = LiveAdapterObservation::RealtimeTranscript {
event: event.clone(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
assert!(
matches!(outcome, ObservationOutcome::TranscriptAppended),
"NoOpProjectionSink must accept RealtimeTranscript explicitly, got {outcome:?}"
);
sink.append_realtime_transcript(&session_id, &event)
.await
.expect("NoOpProjectionSink::append_realtime_transcript must be explicit Ok");
}
#[tokio::test]
async fn realtime_transcript_assistant_turn_completed_routes_through_sink() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
let event = RealtimeTranscriptEvent::AssistantTurnCompleted {
response_id: "resp_complete".into(),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
};
let obs = LiveAdapterObservation::RealtimeTranscript {
event: event.clone(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
assert!(matches!(outcome, ObservationOutcome::TranscriptAppended));
let recorded = sink.realtime_events.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].1, event);
}
#[tokio::test]
async fn tool_call_observation_dispatches_through_tool_authority() {
let sink = Arc::new(RecordingProjectionSink::default());
let dispatcher = Arc::new(RecordingDispatcher::default());
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
.with_tool_dispatcher(Arc::clone(&dispatcher) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_42".into(),
tool_name: "calculator".into(),
arguments: serde_json::json!({"a": 2, "b": 3}),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
match outcome {
ObservationOutcome::ToolCallDispatched {
provider_call_id,
tool_name,
} => {
assert_eq!(provider_call_id, "call_42");
assert_eq!(tool_name, "calculator");
}
other => panic!("expected ToolCallDispatched, got {other:?}"),
}
let calls = dispatcher.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "call_42");
assert_eq!(calls[0].1, "calculator");
let submitted = adapter.submitted_results.lock().unwrap();
assert_eq!(submitted.len(), 1);
assert_eq!(submitted[0].call_id, "call_42");
}
#[tokio::test]
async fn tool_call_skipped_when_no_dispatcher_wired() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_99".into(),
tool_name: "calculator".into(),
arguments: serde_json::json!({}),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
match outcome {
ObservationOutcome::ToolCallSkipped {
reason: ToolDispatchSkipReason::NoDispatcher,
..
} => {}
other => panic!("expected ToolCallSkipped/NoDispatcher, got {other:?}"),
}
}
#[tokio::test]
async fn tool_call_no_dispatcher_submits_error_to_adapter() {
let sink = Arc::new(RecordingProjectionSink::default());
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_unwired".into(),
tool_name: "calculator".into(),
arguments: serde_json::json!({}),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
match outcome {
ObservationOutcome::ToolCallSkipped {
provider_call_id,
tool_name,
reason: ToolDispatchSkipReason::NoDispatcher,
} => {
assert_eq!(provider_call_id, "call_unwired");
assert_eq!(tool_name, "calculator");
}
other => panic!("expected ToolCallSkipped/NoDispatcher, got {other:?}"),
}
let errors = adapter.submitted_errors.lock().unwrap();
assert_eq!(
errors.len(),
1,
"adapter must receive exactly one SubmitToolError when dispatcher is missing"
);
assert_eq!(errors[0].0, "call_unwired");
assert!(
errors[0].1.contains("dispatcher"),
"error message should mention the missing dispatcher; got {:?}",
errors[0].1
);
assert!(adapter.submitted_results.lock().unwrap().is_empty());
}
#[tokio::test]
async fn set_tool_dispatcher_late_binds_after_construction() {
let sink = Arc::new(RecordingProjectionSink::default());
let dispatcher = Arc::new(RecordingDispatcher::default());
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_pre".into(),
tool_name: "calc".into(),
arguments: serde_json::json!({}),
};
match host.apply_observation(&ch, &obs).await.unwrap() {
ObservationOutcome::ToolCallSkipped {
reason: ToolDispatchSkipReason::NoDispatcher,
..
} => {}
other => panic!("expected pre-set skip, got {other:?}"),
}
assert_eq!(dispatcher.calls.lock().unwrap().len(), 0);
host.set_tool_dispatcher(Arc::clone(&dispatcher) as _);
let obs2 = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_post".into(),
tool_name: "calc".into(),
arguments: serde_json::json!({"x": 1}),
};
match host.apply_observation(&ch, &obs2).await.unwrap() {
ObservationOutcome::ToolCallDispatched {
provider_call_id, ..
} => {
assert_eq!(provider_call_id, "call_post");
}
other => panic!("expected post-set dispatch, got {other:?}"),
}
let calls = dispatcher.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "call_post");
}
#[tokio::test]
async fn set_tool_dispatcher_replaces_previously_installed_dispatcher() {
let sink = Arc::new(RecordingProjectionSink::default());
let first = Arc::new(RecordingDispatcher::default());
let second = Arc::new(RecordingDispatcher::default());
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
.with_tool_dispatcher(Arc::clone(&first) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
host.set_tool_dispatcher(Arc::clone(&second) as _);
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_swap".into(),
tool_name: "calc".into(),
arguments: serde_json::json!({}),
};
host.apply_observation(&ch, &obs).await.unwrap();
assert_eq!(first.calls.lock().unwrap().len(), 0);
assert_eq!(second.calls.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn tool_call_dispatch_error_submits_tool_error_to_adapter() {
let sink = Arc::new(RecordingProjectionSink::default());
let dispatcher = Arc::new(FailingDispatcher);
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
.with_tool_dispatcher(Arc::clone(&dispatcher) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_err".into(),
tool_name: "failing".into(),
arguments: serde_json::json!({}),
};
host.apply_observation(&ch, &obs).await.unwrap();
let errors = adapter.submitted_errors.lock().unwrap();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].0, "call_err");
}
struct SlowDispatcher {
sleep_for: Duration,
calls: StdMutex<u32>,
}
impl SlowDispatcher {
fn new(sleep_for: Duration) -> Self {
Self {
sleep_for,
calls: StdMutex::new(0),
}
}
}
#[async_trait]
impl AgentToolDispatcher for SlowDispatcher {
fn tools(&self) -> Arc<[Arc<ToolDef>]> {
Arc::from([])
}
fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
ToolCatalogCapabilities::default()
}
fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
Arc::from([])
}
fn pending_catalog_sources(&self) -> Arc<[String]> {
Arc::from([])
}
async fn dispatch(
&self,
call: meerkat_core::types::ToolCallView<'_>,
) -> Result<ToolDispatchOutcome, meerkat_core::error::ToolError> {
*self.calls.lock().unwrap() += 1;
tokio::time::sleep(self.sleep_for).await;
let tool_result =
meerkat_core::types::ToolResult::new(call.id.to_string(), "ok".into(), false);
Ok(ToolDispatchOutcome::from(tool_result))
}
fn capabilities(&self) -> DispatcherCapabilities {
DispatcherCapabilities::default()
}
}
#[tokio::test(start_paused = true)]
async fn realtime_tool_timeout() {
let timeout = Duration::from_millis(500);
let dispatcher_sleep = Duration::from_secs(60);
let sink = Arc::new(RecordingProjectionSink::default());
let dispatcher = Arc::new(SlowDispatcher::new(dispatcher_sleep));
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
.with_tool_dispatcher(Arc::clone(&dispatcher) as _)
.with_tool_timeout(timeout);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_slow".into(),
tool_name: "slow_tool".into(),
arguments: serde_json::json!({"q": 1}),
};
let host_call = async { host.apply_observation(&ch, &obs).await.unwrap() };
let drive_clock = async {
tokio::task::yield_now().await;
tokio::time::advance(timeout + Duration::from_millis(1)).await;
};
let (outcome, _) = tokio::join!(host_call, drive_clock);
match outcome {
ObservationOutcome::ToolCallTimedOut {
provider_call_id,
tool_name,
timeout: t,
} => {
assert_eq!(provider_call_id, "call_slow");
assert_eq!(tool_name, "slow_tool");
assert_eq!(t, timeout);
}
other => panic!("expected ToolCallTimedOut, got {other:?}"),
}
assert_eq!(*dispatcher.calls.lock().unwrap(), 1);
let errors = adapter.submitted_errors.lock().unwrap();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].0, "call_slow");
assert!(
errors[0].1.contains("timeout"),
"tool error message should mention timeout: {}",
errors[0].1
);
let results = adapter.submitted_results.lock().unwrap();
assert!(
results.is_empty(),
"no SubmitToolResult should reach the adapter on timeout: {results:?}"
);
assert_eq!(sink.text_finals.lock().unwrap().len(), 0);
assert_eq!(sink.transcript_finals.lock().unwrap().len(), 0);
assert_eq!(sink.terminal_errors.lock().unwrap().len(), 0);
}
#[tokio::test(start_paused = true)]
async fn tool_call_dispatch_succeeds_when_within_deadline() {
let timeout = Duration::from_secs(5);
let dispatcher_sleep = Duration::from_millis(100);
let sink = Arc::new(RecordingProjectionSink::default());
let dispatcher = Arc::new(SlowDispatcher::new(dispatcher_sleep));
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
.with_tool_dispatcher(Arc::clone(&dispatcher) as _)
.with_tool_timeout(timeout);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_fast".into(),
tool_name: "fast_tool".into(),
arguments: serde_json::json!({}),
};
let host_call = async { host.apply_observation(&ch, &obs).await.unwrap() };
let drive_clock = async {
tokio::task::yield_now().await;
tokio::time::advance(dispatcher_sleep + Duration::from_millis(10)).await;
};
let (outcome, _) = tokio::join!(host_call, drive_clock);
match outcome {
ObservationOutcome::ToolCallDispatched {
provider_call_id, ..
} => assert_eq!(provider_call_id, "call_fast"),
other => panic!("expected ToolCallDispatched, got {other:?}"),
}
let results = adapter.submitted_results.lock().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].call_id, "call_fast");
assert!(adapter.submitted_errors.lock().unwrap().is_empty());
}
#[tokio::test]
async fn tool_call_dispatch_without_timeout_preserves_unbounded_await() {
let sink = Arc::new(RecordingProjectionSink::default());
let dispatcher = Arc::new(RecordingDispatcher::default());
let adapter = Arc::new(RecordingAdapter::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _)
.with_tool_dispatcher(Arc::clone(&dispatcher) as _);
let ch = host.open_channel(test_session_id()).await.unwrap();
host.attach_adapter(&ch, Arc::clone(&adapter) as _)
.await
.unwrap();
host.apply_status_update(&ch, LiveAdapterStatus::Ready)
.await
.unwrap();
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_legacy".into(),
tool_name: "calc".into(),
arguments: serde_json::json!({}),
};
match host.apply_observation(&ch, &obs).await.unwrap() {
ObservationOutcome::ToolCallDispatched { .. } => {}
other => panic!("expected ToolCallDispatched on no-timeout host, got {other:?}"),
}
assert_eq!(adapter.submitted_results.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn barge_in_observation_calls_signal_interrupt() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let outcome = host
.apply_observation(
&ch,
&LiveAdapterObservation::TurnInterrupted {
response_id: Some("resp_42".into()),
},
)
.await
.unwrap();
assert!(matches!(outcome, ObservationOutcome::InterruptSignalled));
let interrupts = sink.interrupts.lock().unwrap();
assert_eq!(interrupts.len(), 1);
assert_eq!(interrupts[0].0, session_id);
assert_eq!(interrupts[0].1.as_deref(), Some("resp_42"));
}
#[tokio::test]
async fn terminal_error_observation_marks_channel_closed_and_signals_sink() {
let sink = Arc::new(RecordingProjectionSink::default());
let host = LiveAdapterHost::new(Arc::clone(&sink) as _);
let session_id = test_session_id();
let ch = host.open_channel(session_id.clone()).await.unwrap();
let obs = LiveAdapterObservation::Error {
code: LiveAdapterErrorCode::ConnectionLost,
message: "ws closed unexpectedly".into(),
};
let outcome = host.apply_observation(&ch, &obs).await.unwrap();
match outcome {
ObservationOutcome::Terminal {
code: LiveAdapterErrorCode::ConnectionLost,
} => {}
other => panic!("expected Terminal/ConnectionLost, got {other:?}"),
}
let status = host.channel_status(&ch).await.unwrap();
assert_eq!(status, LiveAdapterStatus::Closed);
let terminals = sink.terminal_errors.lock().unwrap();
assert_eq!(terminals.len(), 1);
assert_eq!(terminals[0].0, session_id);
assert!(matches!(
terminals[0].1,
LiveAdapterErrorCode::ConnectionLost
));
}
#[tokio::test]
async fn duplicate_session_check_uses_reverse_map() {
let host = LiveAdapterHost::new(Arc::new(NoOpProjectionSink));
let s = test_session_id();
let ch = host.open_channel(s.clone()).await.unwrap();
host.close_channel(&ch).await.unwrap();
host.open_channel(s).await.unwrap();
}
struct StubAdapter;
impl StubAdapter {
fn new() -> Self {
Self
}
}
#[async_trait]
impl LiveAdapter for StubAdapter {
async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
Ok(())
}
async fn next_observation(
&self,
) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
Ok(None)
}
fn status(&self) -> LiveAdapterStatus {
LiveAdapterStatus::Ready
}
async fn close(&self) -> Result<(), LiveAdapterError> {
Ok(())
}
}
struct ErroringAdapter;
#[async_trait]
impl LiveAdapter for ErroringAdapter {
async fn send_command(&self, _command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
Ok(())
}
async fn next_observation(
&self,
) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
Err(LiveAdapterError::TransportError {
message: "pump dead".into(),
})
}
fn status(&self) -> LiveAdapterStatus {
LiveAdapterStatus::Closed
}
async fn close(&self) -> Result<(), LiveAdapterError> {
Ok(())
}
}
#[derive(Default)]
struct RecordingAdapter {
submitted_results: StdMutex<Vec<LiveToolResult>>,
submitted_errors: StdMutex<Vec<(String, String)>>,
}
#[async_trait]
impl LiveAdapter for RecordingAdapter {
async fn send_command(&self, command: LiveAdapterCommand) -> Result<(), LiveAdapterError> {
match command {
LiveAdapterCommand::SubmitToolResult { result } => {
self.submitted_results.lock().unwrap().push(result);
}
LiveAdapterCommand::SubmitToolError { call_id, error } => {
self.submitted_errors.lock().unwrap().push((call_id, error));
}
_ => {}
}
Ok(())
}
async fn next_observation(
&self,
) -> Result<Option<LiveAdapterObservation>, LiveAdapterError> {
Ok(None)
}
fn status(&self) -> LiveAdapterStatus {
LiveAdapterStatus::Ready
}
async fn close(&self) -> Result<(), LiveAdapterError> {
Ok(())
}
}
#[derive(Default)]
struct RecordingDispatcher {
calls: StdMutex<Vec<(String, String, String)>>,
}
#[async_trait]
impl AgentToolDispatcher for RecordingDispatcher {
fn tools(&self) -> Arc<[Arc<ToolDef>]> {
Arc::from([])
}
fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
ToolCatalogCapabilities::default()
}
fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
Arc::from([])
}
fn pending_catalog_sources(&self) -> Arc<[String]> {
Arc::from([])
}
async fn dispatch(
&self,
call: meerkat_core::types::ToolCallView<'_>,
) -> Result<ToolDispatchOutcome, meerkat_core::error::ToolError> {
self.calls.lock().unwrap().push((
call.id.to_string(),
call.name.to_string(),
call.args.get().to_string(),
));
let tool_result =
meerkat_core::types::ToolResult::new(call.id.to_string(), "ok".into(), false);
Ok(ToolDispatchOutcome::from(tool_result))
}
fn capabilities(&self) -> DispatcherCapabilities {
DispatcherCapabilities::default()
}
}
struct FailingDispatcher;
#[async_trait]
impl AgentToolDispatcher for FailingDispatcher {
fn tools(&self) -> Arc<[Arc<ToolDef>]> {
Arc::from([])
}
fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
ToolCatalogCapabilities::default()
}
fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
Arc::from([])
}
fn pending_catalog_sources(&self) -> Arc<[String]> {
Arc::from([])
}
async fn dispatch(
&self,
_call: meerkat_core::types::ToolCallView<'_>,
) -> Result<ToolDispatchOutcome, meerkat_core::error::ToolError> {
Err(meerkat_core::error::ToolError::ExecutionFailed {
message: "bang".into(),
})
}
fn capabilities(&self) -> DispatcherCapabilities {
DispatcherCapabilities::default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct OwnedIdentity {
provider_item_id: Option<String>,
previous_item_id: Option<String>,
content_index: Option<u32>,
response_id: Option<String>,
delta_id: Option<String>,
}
impl OwnedIdentity {
fn from_borrowed(identity: LiveTranscriptIdentity<'_>) -> Self {
Self {
provider_item_id: identity.provider_item_id.map(|s| s.to_string()),
previous_item_id: identity.previous_item_id.map(|s| s.to_string()),
content_index: identity.content_index,
response_id: identity.response_id.map(|s| s.to_string()),
delta_id: identity.delta_id.map(|s| s.to_string()),
}
}
}
#[derive(Default)]
#[allow(clippy::type_complexity)]
struct RecordingProjectionSink {
user_transcripts: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
text_deltas: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
transcript_deltas: StdMutex<Vec<(SessionId, String, OwnedIdentity)>>,
text_finals: StdMutex<
Vec<(
SessionId,
String,
OwnedIdentity,
StopReason,
Usage,
Option<String>,
)>,
>,
transcript_finals: StdMutex<
Vec<(
SessionId,
String,
OwnedIdentity,
StopReason,
Usage,
Option<String>,
)>,
>,
truncations: StdMutex<
Vec<(
SessionId,
Option<String>,
Option<String>,
Option<u32>,
Option<String>,
Option<String>,
)>,
>,
interrupts: StdMutex<Vec<(SessionId, Option<String>)>>,
turn_completed: StdMutex<Vec<(SessionId, StopReason, Usage, Option<String>)>>,
terminal_errors: StdMutex<Vec<(SessionId, LiveAdapterErrorCode, String)>>,
realtime_events: StdMutex<Vec<(SessionId, RealtimeTranscriptEvent)>>,
}
#[async_trait]
impl LiveProjectionSink for RecordingProjectionSink {
async fn append_user_transcript(
&self,
session_id: &SessionId,
text: &str,
identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError> {
self.user_transcripts.lock().unwrap().push((
session_id.clone(),
text.to_string(),
OwnedIdentity::from_borrowed(identity),
));
Ok(())
}
async fn append_assistant_text_delta(
&self,
session_id: &SessionId,
delta: &str,
identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError> {
self.text_deltas.lock().unwrap().push((
session_id.clone(),
delta.to_string(),
OwnedIdentity::from_borrowed(identity),
));
Ok(())
}
async fn append_assistant_transcript_delta(
&self,
session_id: &SessionId,
delta: &str,
identity: LiveTranscriptIdentity<'_>,
) -> Result<(), LiveProjectionError> {
self.transcript_deltas.lock().unwrap().push((
session_id.clone(),
delta.to_string(),
OwnedIdentity::from_borrowed(identity),
));
Ok(())
}
async fn append_assistant_text_final(
&self,
session_id: &SessionId,
text: &str,
identity: LiveTranscriptIdentity<'_>,
stop_reason: StopReason,
usage: Usage,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
self.text_finals.lock().unwrap().push((
session_id.clone(),
text.to_string(),
OwnedIdentity::from_borrowed(identity),
stop_reason,
usage,
response_id.map(|s| s.to_string()),
));
Ok(())
}
async fn append_assistant_transcript_final(
&self,
session_id: &SessionId,
text: &str,
identity: LiveTranscriptIdentity<'_>,
stop_reason: StopReason,
usage: Usage,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
self.transcript_finals.lock().unwrap().push((
session_id.clone(),
text.to_string(),
OwnedIdentity::from_borrowed(identity),
stop_reason,
usage,
response_id.map(|s| s.to_string()),
));
Ok(())
}
async fn truncate_assistant_transcript(
&self,
session_id: &SessionId,
provider_item_id: Option<&str>,
previous_item_id: Option<&str>,
content_index: Option<u32>,
response_id: Option<&str>,
text: Option<&str>,
) -> Result<(), LiveProjectionError> {
self.truncations.lock().unwrap().push((
session_id.clone(),
provider_item_id.map(|s| s.to_string()),
previous_item_id.map(|s| s.to_string()),
content_index,
response_id.map(|s| s.to_string()),
text.map(|s| s.to_string()),
));
Ok(())
}
async fn signal_turn_interrupt(
&self,
session_id: &SessionId,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
self.interrupts
.lock()
.unwrap()
.push((session_id.clone(), response_id.map(|s| s.to_string())));
Ok(())
}
async fn signal_turn_completed(
&self,
session_id: &SessionId,
stop_reason: StopReason,
usage: Usage,
response_id: Option<&str>,
) -> Result<(), LiveProjectionError> {
self.turn_completed.lock().unwrap().push((
session_id.clone(),
stop_reason,
usage,
response_id.map(|s| s.to_string()),
));
Ok(())
}
async fn signal_terminal_error(
&self,
session_id: &SessionId,
code: LiveAdapterErrorCode,
message: &str,
) -> Result<(), LiveProjectionError> {
self.terminal_errors.lock().unwrap().push((
session_id.clone(),
code,
message.to_string(),
));
Ok(())
}
async fn append_realtime_transcript(
&self,
session_id: &SessionId,
event: &RealtimeTranscriptEvent,
) -> Result<(), LiveProjectionError> {
self.realtime_events
.lock()
.unwrap()
.push((session_id.clone(), event.clone()));
Ok(())
}
}
}