Files
Skald-Circle/crates/agent-loop/src/testing.rs
T
dguiducci ea31fad188
Nightly Build / build (push) Successful in 7m49s
fix(llm): send the provider model id on the wire, not the alias
LlmManager keys its model registry by llm_models.name (the user-facing
alias), and the kernel sent ModelHandle.id as the request's model field —
so the alias, not llm_models.model_id, went on the wire. A model worked
only while the alias was left equal to the model id; renaming it made
every provider reject the call (DeepInfra 404 model_not_found, DeepSeek
400 invalid_request_error).

ModelHandle gains an optional wire_id: the model identifier to put on the
wire when the selector's id is a bookkeeping key. The kernel and the
compaction summary call both send handle.wire_model(); SkaldSelector sets
wire_id from LlmEntry.model (llm_models.model_id). Everything else keeps
keying on the alias: the chat's model pin, health reporting, fallback
exclusion and request logging are untouched.
2026-08-07 14:55:58 +01:00

132 lines
4.2 KiB
Rust

//! Test utilities: a scripted `FakeModel` + builders for kernel and recovery
//! scenarios. (Blueprint: will move behind a `test-util` feature if the crate
//! is ever published.)
use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::model::{
Model, ModelError, ModelRequest, ModelResponse, NamedModel, StreamDelta, ToolCall, Usage,
};
/// One scripted step: the response (or error) plus optional deltas to emit
/// before returning.
pub struct Step {
pub result: Result<ModelResponse, ModelError>,
pub deltas: Vec<StreamDelta>,
/// Never return (cancellation tests).
pub pending: bool,
}
impl Step {
pub fn message(content: impl Into<String>) -> Self {
Self { result: Ok(ModelResponse::message(content)), deltas: Vec::new(), pending: false }
}
pub fn message_with_usage(content: impl Into<String>, input: u32, output: u32) -> Self {
let mut resp = ModelResponse::message(content);
*resp.usage_mut() = Usage {
input_tokens: Some(input),
output_tokens: Some(output),
..Usage::default()
};
Self { result: Ok(resp), deltas: Vec::new(), pending: false }
}
pub fn tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Self {
Self { result: Ok(ModelResponse::tool_calls(content, calls)), deltas: Vec::new(), pending: false }
}
pub fn error(status: Option<u16>, message: impl Into<String>) -> Self {
Self { result: Err(ModelError::new(status, message)), deltas: Vec::new(), pending: false }
}
/// Never completes — the only way out is cancelling the turn.
pub fn pending() -> Self {
Self { result: Ok(ModelResponse::message("")), deltas: Vec::new(), pending: true }
}
/// Stream these deltas (in order) before returning the response.
pub fn with_deltas(mut self, deltas: Vec<StreamDelta>) -> Self {
self.deltas = deltas;
self
}
}
/// A scripted model: pops one [`Step`] per `complete` call, records every
/// request for assertions. Clone the `Arc` around it to inspect afterwards.
pub struct FakeModel {
script: Mutex<VecDeque<Step>>,
requests: Mutex<Vec<ModelRequest>>,
default_model: String,
}
impl FakeModel {
pub fn new(default_model: impl Into<String>, script: Vec<Step>) -> Self {
Self {
script: Mutex::new(script.into()),
requests: Mutex::new(Vec::new()),
default_model: default_model.into(),
}
}
/// All requests seen so far (one per attempt, fallback included).
pub fn requests(&self) -> Vec<ModelRequest> {
self.requests.lock().unwrap().clone()
}
/// Steps not yet consumed (assert a script was fully driven).
pub fn remaining(&self) -> usize {
self.script.lock().unwrap().len()
}
}
impl NamedModel for FakeModel {
fn default_model(&self) -> &str { &self.default_model }
}
#[async_trait]
impl Model for FakeModel {
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
self.requests.lock().unwrap().push(req.clone());
let step = self
.script
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("FakeModel: script exhausted (request for model {})", req.model));
if let Some(tx) = deltas {
for d in step.deltas {
let _ = tx.try_send(d);
}
}
if step.pending {
std::future::pending::<()>().await;
}
step.result
}
}
/// A `ModelHandle` over a shared `FakeModel` (tests keep the Arc to inspect
/// `requests()` afterwards).
pub fn handle(fake: &std::sync::Arc<FakeModel>, id: &str) -> crate::model::ModelHandle {
crate::model::ModelHandle {
id: id.to_string(),
model: fake.clone(),
info: crate::model::ModelInfo::default(),
wire_id: None,
}
}
/// Build a wire `ToolCall` compactly in tests.
pub fn call(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
ToolCall { id: id.to_string(), name: name.to_string(), arguments: args }
}