use anda_core::{AgentOutput, BoxError, BoxPinFut, CompletionRequest, ContentPart, Usage};
use parking_lot::Mutex;
use std::{collections::VecDeque, sync::Arc};
use super::CompletionFeaturesDyn;
type Reply = Box<dyn FnOnce(&CompletionRequest) -> Result<AgentOutput, BoxError> + Send>;
pub struct ScriptedCompleter {
name: String,
script: Mutex<VecDeque<Reply>>,
requests: Mutex<Vec<CompletionRequest>>,
}
impl ScriptedCompleter {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
script: Mutex::new(VecDeque::new()),
requests: Mutex::new(Vec::new()),
}
}
pub fn push_output(self, output: AgentOutput) -> Self {
self.script.lock().push_back(Box::new(move |_| Ok(output)));
self
}
pub fn push_error(self, message: impl Into<String>) -> Self {
let message = message.into();
self.script
.lock()
.push_back(Box::new(move |_| Err(message.into())));
self
}
pub fn push_with<F>(self, reply: F) -> Self
where
F: FnOnce(&CompletionRequest) -> Result<AgentOutput, BoxError> + Send + 'static,
{
self.script.lock().push_back(Box::new(reply));
self
}
pub fn into_arc(self) -> Arc<Self> {
Arc::new(self)
}
pub fn requests(&self) -> Vec<CompletionRequest> {
self.requests.lock().clone()
}
fn echo(req: &CompletionRequest) -> AgentOutput {
let content = if req.prompt.is_empty() {
req.content
.iter()
.filter_map(|part| match part {
ContentPart::Text { text } | ContentPart::Reasoning { text } => {
Some(text.clone())
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n")
} else {
req.prompt.clone()
};
AgentOutput {
content,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cached_tokens: 0,
requests: 1,
},
..Default::default()
}
}
}
impl CompletionFeaturesDyn for ScriptedCompleter {
fn model_name(&self) -> String {
self.name.clone()
}
fn completion(&self, req: CompletionRequest) -> BoxPinFut<Result<AgentOutput, BoxError>> {
self.requests.lock().push(req.clone());
let reply = self.script.lock().pop_front();
let result = match reply {
Some(reply) => reply(&req),
None => Ok(Self::echo(&req)),
};
Box::pin(futures::future::ready(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Model;
#[tokio::test]
async fn scripted_completer_pops_replies_then_echoes_and_records() {
let completer = ScriptedCompleter::new("scripted")
.push_output(AgentOutput {
content: "first".to_string(),
..Default::default()
})
.push_with(|req| {
Ok(AgentOutput {
content: format!("saw: {}", req.prompt),
..Default::default()
})
})
.push_error("boom")
.into_arc();
let model = Model::with_completer(completer.clone());
let req = |prompt: &str| CompletionRequest {
prompt: prompt.to_string(),
..Default::default()
};
assert_eq!(model.completion(req("a")).await.unwrap().content, "first");
assert_eq!(model.completion(req("b")).await.unwrap().content, "saw: b");
assert_eq!(
model.completion(req("c")).await.unwrap_err().to_string(),
"boom"
);
let echoed = model.completion(req("tail")).await.unwrap();
assert_eq!(echoed.content, "tail");
assert_eq!(echoed.usage.requests, 1);
let seen: Vec<String> = completer.requests().into_iter().map(|r| r.prompt).collect();
assert_eq!(seen, vec!["a", "b", "c", "tail"]);
}
}