use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use motosan_agent_loop::{
ChatOutput, LlmClient, LlmResponse, Message, Result as MotosanResult, ToolCallItem,
};
use motosan_agent_tool::ToolDef;
use serde_json::Value;
pub struct ScriptedLlm {
script: Mutex<VecDeque<LlmResponse>>,
captured: Mutex<Vec<Vec<Message>>>,
}
impl ScriptedLlm {
pub fn builder() -> ScriptedLlmBuilder {
ScriptedLlmBuilder {
script: VecDeque::new(),
}
}
pub fn captured_inputs(&self) -> Vec<Vec<Message>> {
self.captured
.lock()
.map(|c| c.clone())
.unwrap_or_else(|poison| poison.into_inner().clone())
}
}
#[async_trait]
impl LlmClient for ScriptedLlm {
async fn chat(&self, messages: &[Message], _tools: &[ToolDef]) -> MotosanResult<ChatOutput> {
if let Ok(mut captured) = self.captured.lock() {
captured.push(messages.to_vec());
}
let next = match self.script.lock() {
Ok(mut s) => s.pop_front(),
Err(poison) => poison.into_inner().pop_front(),
};
let response = next.unwrap_or_else(|| {
panic!("ScriptedLlm: script exhausted (chat called more times than scripted)")
});
Ok(ChatOutput::new(response))
}
}
pub struct ScriptedLlmBuilder {
script: VecDeque<LlmResponse>,
}
impl ScriptedLlmBuilder {
pub fn then_message(mut self, text: impl Into<String>) -> Self {
self.script.push_back(LlmResponse::Message(text.into()));
self
}
pub fn then_tool_call(
mut self,
id: impl Into<String>,
name: impl Into<String>,
args: Value,
) -> Self {
self.script
.push_back(LlmResponse::ToolCalls(vec![ToolCallItem {
id: id.into(),
name: name.into(),
args,
}]));
self
}
pub fn build(self) -> ScriptedLlm {
ScriptedLlm {
script: Mutex::new(self.script),
captured: Mutex::new(Vec::new()),
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use motosan_agent_loop::LlmClient;
#[tokio::test]
async fn scripted_llm_replays_in_order() {
let llm = ScriptedLlm::builder()
.then_message("first")
.then_message("second")
.build();
let a = llm.chat(&[], &[]).await.expect("chat 1");
let b = llm.chat(&[], &[]).await.expect("chat 2");
assert!(matches!(
a.response,
motosan_agent_loop::LlmResponse::Message(ref s) if s == "first"
));
assert!(matches!(
b.response,
motosan_agent_loop::LlmResponse::Message(ref s) if s == "second"
));
}
#[tokio::test]
async fn scripted_llm_then_tool_call_emits_tool_calls_variant() {
let llm = ScriptedLlm::builder()
.then_tool_call("t1", "bash", serde_json::json!({"command": "echo hi"}))
.build();
let out = llm.chat(&[], &[]).await.expect("chat");
let calls = match out.response {
motosan_agent_loop::LlmResponse::ToolCalls(c) => c,
other => panic!("expected ToolCalls, got {other:?}"),
};
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "t1");
assert_eq!(calls[0].name, "bash");
assert_eq!(calls[0].args["command"], "echo hi");
}
#[tokio::test]
async fn scripted_llm_captures_chat_inputs() {
let llm = ScriptedLlm::builder().then_message("ack").build();
let msg = motosan_agent_loop::Message::user("hello");
let _ = llm
.chat(std::slice::from_ref(&msg), &[])
.await
.expect("chat");
let captured = llm.captured_inputs();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].len(), 1);
}
#[tokio::test]
#[should_panic(expected = "script exhausted")]
async fn scripted_llm_panics_when_script_runs_out() {
let llm = ScriptedLlm::builder().build();
let _ = llm.chat(&[], &[]).await;
}
}