use super::traits::*;
use crate::types::*;
use async_trait::async_trait;
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MockResponse {
Text(String),
ToolCalls(Vec<MockToolCall>),
TextWithUsage(String, Usage),
ToolCallsWithUsage(Vec<MockToolCall>, Usage),
ErrorWithUsage(String, Usage),
}
#[derive(Debug, Clone)]
pub struct MockToolCall {
pub name: String,
pub arguments: serde_json::Value,
#[allow(dead_code)]
pub provider_metadata: Option<serde_json::Value>,
}
pub struct MockProvider {
responses: std::sync::Mutex<Vec<MockResponse>>,
validate_transcript: bool,
}
fn transcript_violation(messages: &[Message]) -> Option<String> {
let mut pending: Vec<String> = Vec::new();
for (i, msg) in messages.iter().enumerate() {
match msg {
Message::Assistant { content, .. } => {
if !pending.is_empty() {
return Some(format!(
"assistant message at [{i}] while tool calls {pending:?} are still \
unanswered"
));
}
pending = content
.iter()
.filter_map(|c| match c {
Content::ToolCall { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
}
Message::ToolResult { tool_call_id, .. } => {
match pending.iter().position(|p| p == tool_call_id) {
Some(at) => {
pending.remove(at);
}
None => {
return Some(format!(
"tool_result at [{i}] for {tool_call_id:?} answers no open tool call"
))
}
}
}
Message::User { .. } => {
if !pending.is_empty() {
return Some(format!(
"a user message at [{i}] lands between an assistant's tool_use and its \
tool_result — {pending:?} still unanswered"
));
}
}
}
}
None
}
impl MockProvider {
pub fn new(responses: Vec<MockResponse>) -> Self {
Self {
responses: std::sync::Mutex::new(responses),
validate_transcript: true,
}
}
pub fn without_transcript_validation(mut self) -> Self {
self.validate_transcript = false;
self
}
pub fn text(text: impl Into<String>) -> Self {
Self::new(vec![MockResponse::Text(text.into())])
}
pub fn texts(texts: Vec<impl Into<String>>) -> Self {
Self::new(
texts
.into_iter()
.map(|t| MockResponse::Text(t.into()))
.collect(),
)
}
}
#[async_trait]
impl StreamProvider for MockProvider {
async fn stream(
&self,
config: StreamConfig,
tx: mpsc::UnboundedSender<StreamEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<Message, ProviderError> {
if self.validate_transcript {
if let Some(why) = transcript_violation(&config.messages) {
panic!(
"MockProvider received a transcript a real provider would reject: {why}.\n\n\
This is the shape that poisons an agent — the history is kept, so every \
later prompt fails too. If the malformed sequence is deliberate, use \
`MockProvider::without_transcript_validation()` and say why.\n\n\
Messages: {:#?}",
config.messages
);
}
}
let response = {
let mut responses = self.responses.lock().unwrap();
if responses.is_empty() {
MockResponse::Text("(no more mock responses)".into())
} else {
responses.remove(0)
}
};
if cancel.is_cancelled() {
return Err(ProviderError::Cancelled);
}
let _ = tx.send(StreamEvent::Start);
let message = match response {
MockResponse::TextWithUsage(text, usage) => {
let _ = tx.send(StreamEvent::TextDelta {
content_index: 0,
delta: text.clone(),
});
Message::Assistant {
content: vec![Content::Text { text }],
stop_reason: StopReason::Stop,
model: "mock".into(),
provider: "mock".into(),
usage,
timestamp: now_ms(),
error_message: None,
}
}
MockResponse::Text(text) => {
let _ = tx.send(StreamEvent::TextDelta {
content_index: 0,
delta: text.clone(),
});
Message::Assistant {
content: vec![Content::Text { text }],
stop_reason: StopReason::Stop,
model: "mock".into(),
provider: "mock".into(),
usage: Usage::default(),
timestamp: now_ms(),
error_message: None,
}
}
MockResponse::ErrorWithUsage(error, usage) => Message::Assistant {
content: vec![],
stop_reason: StopReason::Error,
model: "mock".into(),
provider: "mock".into(),
usage,
timestamp: now_ms(),
error_message: Some(error),
},
MockResponse::ToolCalls(calls) => mock_tool_calls(&tx, calls, Usage::default()),
MockResponse::ToolCallsWithUsage(calls, usage) => mock_tool_calls(&tx, calls, usage),
};
let _ = tx.send(StreamEvent::Done {
message: message.clone(),
});
Ok(message)
}
}
fn mock_tool_calls(
tx: &mpsc::UnboundedSender<StreamEvent>,
calls: Vec<MockToolCall>,
usage: Usage,
) -> Message {
let content: Vec<Content> = calls
.iter()
.enumerate()
.map(|(i, call)| {
let id = format!("mock-tool-{}", i);
let _ = tx.send(StreamEvent::ToolCallStart {
content_index: i,
id: id.clone(),
name: call.name.clone(),
});
let _ = tx.send(StreamEvent::ToolCallEnd { content_index: i });
Content::ToolCall {
id,
name: call.name.clone(),
arguments: call.arguments.clone(),
provider_metadata: call.provider_metadata.clone(),
}
})
.collect();
Message::Assistant {
content,
stop_reason: StopReason::ToolUse,
model: "mock".into(),
provider: "mock".into(),
usage,
timestamp: now_ms(),
error_message: None,
}
}