pub struct MockApiClient { /* private fields */ }Expand description
A mock ApiClient that returns preconfigured streaming responses.
Use this in unit tests to exercise agent logic without making real API calls. Configure what the client returns using the builder-style methods:
with_text_response— set the text the model “says”.with_tool_call— make the model request a tool invocation.with_stop_reason— override the stop reason (e.g."end_turn","tool_use").with_responses— queue multiple responses for multi-turn tests.with_error— simulate an API error on every call.
For multi-turn scenarios, chain multiple MockResponse values via
with_responses. Each call to
stream_messages pops the next response;
once exhausted the last response is repeated.
§Construction
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("test-model")
.with_text_response("I'm here to help!");§Multi-turn
use loopctl::testing::{MockApiClient, MockResponse, MockToolCall};
use serde_json::json;
let client = MockApiClient::new("test-model").with_responses(vec![
MockResponse {
text: "Let me look that up.".into(),
tool_call: Some(MockToolCall {
id: "call_1".into(),
name: "search".into(),
input: json!({"query": "rust"}),
}),
stop_reason: "tool_use".into(),
},
MockResponse {
text: "Here is what I found.".into(),
tool_call: None,
stop_reason: "end_turn".into(),
},
]);Implementations§
Source§impl MockApiClient
Construction and builder methods for MockApiClient.
impl MockApiClient
Construction and builder methods for MockApiClient.
The builder pattern lets you configure mock responses fluently.
All builder methods consume and return Self, so you can chain
them directly after MockApiClient::new.
§Response lifecycle
new preloads the client with a single default
response (text "Hello!", stop reason "end_turn"). The builder methods
then mutate or replace that queue:
with_text_responsemutates the text on the front response.with_tool_calladds a tool call and sets the stop reason to"tool_use".with_stop_reasonoverrides the stop reason.with_responsesreplaces the entire response queue.with_errorforces an error on every call.
Sourcepub fn new(model: &str) -> Self
pub fn new(model: &str) -> Self
Create a new mock client with the given model name.
Returns a client preloaded with a single default response:
text "Hello!" with stop reason "end_turn" and no tool call.
Use the builder methods to customize before passing the client
to the code under test.
The default response is simple so that most tests
only need to call with_text_response
to get started.
§Example
use loopctl::api::ApiClient;
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("test-model");
assert_eq!(client.model(), "test-model");Sourcepub fn with_text_response(self, text: &str) -> Self
pub fn with_text_response(self, text: &str) -> Self
Set the text response for the first (or only) turn.
Overwrites the text field on the initial MockResponse
created by new. Simplest way
to configure a single-turn mock — the model will “say” the given
text and stop.
If you need to set text for multiple turns, use
with_responses instead.
§Example
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("test-model")
.with_text_response("I am a test assistant.");Sourcepub fn with_tool_call(self, id: &str, name: &str, input: Value) -> Self
pub fn with_tool_call(self, id: &str, name: &str, input: Value) -> Self
Add a tool call to the first (or only) response.
The mock will emit a tool_use content block and set the stop
reason to "tool_use" so the agent loop knows to execute the
tool. The id, name, and input parameters map directly to
the fields on MockToolCall.
The tool name should match a tool registered in the agent’s
ToolRegistry — otherwise the
agent loop will fail when it tries to dispatch the call.
§Example
use loopctl::testing::MockApiClient;
use serde_json::json;
let client = MockApiClient::new("test-model")
.with_tool_call("call_1", "bash", json!({"command": "ls"}));Sourcepub fn with_stop_reason(self, reason: &str) -> Self
pub fn with_stop_reason(self, reason: &str) -> Self
Override the stop reason on the first (or only) response.
Common values are "end_turn" (default) and "tool_use".
Note that with_tool_call
automatically sets the stop reason to "tool_use", so this method
should be used when you want a different value.
§Example
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("test-model")
.with_stop_reason("max_tokens");Sourcepub fn with_responses(self, responses: Vec<MockResponse>) -> Self
pub fn with_responses(self, responses: Vec<MockResponse>) -> Self
Set the full response queue for multi-turn behaviour.
Each call to stream_messages
pops the front entry. When only one entry remains it is cloned
and reused, so the mock never panics on an empty queue.
If responses is empty the call is a no-op (the default response
is retained). Recommended way to set up complex
multi-turn scenarios where the model needs to reply differently
across successive turns.
§Example
use loopctl::testing::{MockApiClient, MockResponse};
let client = MockApiClient::new("test-model").with_responses(vec![
MockResponse {
text: "First reply".into(),
tool_call: None,
stop_reason: "end_turn".into(),
},
MockResponse {
text: "Second reply".into(),
tool_call: None,
stop_reason: "end_turn".into(),
},
]);Sourcepub fn with_error(self, error: &str) -> Self
pub fn with_error(self, error: &str) -> Self
Simulate an API error on every call.
Once set, both stream_messages
and create_message will return
an ApiError immediately. This overrides any response
configuration.
Useful for exercising agent error-handling and retry paths.
The error message is passed through to the ApiError so
tests can assert on the specific error string.
§Example
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("test-model")
.with_error("rate limit exceeded");Trait Implementations§
Source§impl ApiClient for MockApiClient
impl ApiClient for MockApiClient
Source§fn model(&self) -> String
fn model(&self) -> String
Return the model name this mock was created with.
Called by the framework to identify which model is being used
throughout the session. Always returns the string passed to
MockApiClient::new — the value is not validated against any
real model registry, so any string is acceptable for testing.
§Example
use loopctl::api::ApiClient;
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("my-test-model");
assert_eq!(client.model(), "my-test-model");Source§fn set_model(&self, model: &str) -> bool
fn set_model(&self, model: &str) -> bool
Hot-swap the mock’s model name at runtime.
Unlike the trait default (which returns false), the mock stores
its model behind a mutex and updates it in place so tests can
exercise BareLoop::switch_model
and verify the new name is observed by subsequent model
calls. Returns false (no-op) when model is empty or whitespace,
matching the trait contract that an empty model is not a valid switch.
Source§fn stream_messages(
&self,
_request: &StreamRequest,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
fn stream_messages( &self, _request: &StreamRequest, ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
Stream a canned sequence of StreamEvents for the next response.
Called by the agent loop to obtain the model’s reply. The mock
translates the current MockResponse into the standard event
sequence:
MessageStart → PartStart(text) → IndexedDelta(text) → MessagePartStop
→ [PartStart(tool_use) → IndexedDelta(input_json)
→ MessagePartStop] (if tool_call is set)
→ MessageDelta(stop_reason, usage) → MessageStopIf with_error was called the stream
contains a single ApiError event instead.
The _messages, _system, and _tools parameters are accepted for
trait compatibility but ignored — the mock always
returns the preconfigured response.
§Usage tokens
The mock always reports 50 input tokens and 25 output tokens in
the MessageDelta event. This lets tests assert on usage data
without needing a real model response.
§Example
use loopctl::api::{ApiClient, StreamRequest};
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("test-model").with_text_response("Hi!");
let stream = client.stream_messages(&StreamRequest::new(vec![]));
let events: Vec<_> = futures::StreamExt::collect(stream).await;
assert!(events.len() >= 4);Source§fn stream_messages_with_options(
&self,
_request: &StreamRequest,
options: RequestOptions,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
fn stream_messages_with_options( &self, _request: &StreamRequest, options: RequestOptions, ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
Streaming variant that honors the per-request model override.
Accepts a response_format by serving the canned response
unchanged — the mock cannot enforce a schema (it has no model),
so the canned text is the structured answer, well-formed or
deliberately malformed. Rejects tool_constraint loudly (a
constraint shapes the tool-calling path the mock scripts).
Serves the response under
options.model when set — the mock’s stand-in for the wire-level
model switch real clients perform, so fallback-routing tests
built on the mock observe the routed model instead of silently
receiving the constructor’s name.
§Example
use futures::StreamExt;
use loopctl::api::{ApiClient, StreamRequest};
use loopctl::structured::RequestOptions;
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("primary").with_text_response("Hi!");
let opts = RequestOptions::default().with_model("fallback");
let stream = client.stream_messages_with_options(&StreamRequest::new(vec![]), opts);
let events: Vec<_> = stream.collect().await;Source§fn create_message(
&self,
_request: &StreamRequest,
) -> Pin<Box<dyn Future<Output = Result<NonStreamingResponse, ApiError>> + Send + '_>>
fn create_message( &self, _request: &StreamRequest, ) -> Pin<Box<dyn Future<Output = Result<NonStreamingResponse, ApiError>> + Send + '_>>
Return a canned non-streaming NonStreamingResponse.
Called by code paths that use the non-streaming API. The mock
translates the current MockResponse into a typed
NonStreamingResponse carrying an
assistant Message built from the response’s text and optional tool
call, plus the stop reason parsed from MockResponse::stop_reason.
If with_error was called the
future resolves to an ApiError instead, bypassing the
response queue entirely.
Usage mirrors the streaming twin: the response reports 50 input tokens and 25 output tokens, so tests assert the same usage on either path.
The _request parameter is accepted for trait compatibility but ignored.
§Example
use loopctl::api::{ApiClient, StreamRequest};
use loopctl::testing::MockApiClient;
let client = MockApiClient::new("test-model").with_text_response("Hi!");
let result = client.create_message(&StreamRequest::new(vec![])).await;
assert_eq!(result.unwrap().message.text_content(), "Hi!");Source§fn create_message_with_options(
&self,
request: &StreamRequest,
options: RequestOptions,
) -> Pin<Box<dyn Future<Output = Result<NonStreamingResponse, ApiError>> + Send + '_>>
fn create_message_with_options( &self, request: &StreamRequest, options: RequestOptions, ) -> Pin<Box<dyn Future<Output = Result<NonStreamingResponse, ApiError>> + Send + '_>>
Non-streaming variant that accepts the per-request model override.
Accepts a response_format by serving the canned response
unchanged (see the streaming twin for why), and rejects
tool_constraint loudly. The per-request model is accepted without
changing the response: a NonStreamingResponse
carries no model field, so there is nothing to vary — accepting
the override keeps fallback-routing tests on the non-streaming
path running instead of failing on a field the mock does forward
on its streaming twin.
Source§impl Clone for MockApiClient
impl Clone for MockApiClient
Source§fn clone(&self) -> MockApiClient
fn clone(&self) -> MockApiClient
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more