返回 CodeWhale
mock.rs
根目录 / crates / tui / src / llm_client / mock.rs
1 //! `MockLlmClient` — a queue-driven `LlmClient` implementation for tests.
2 //!
3 //! This client implements the [`LlmClient`](super::LlmClient) trait by replaying a
4 //! pre-loaded queue of canned responses (one per turn). It captures every
5 //! request the runtime sends so tests can assert on the outgoing payload —
6 //! e.g. confirming that prior `reasoning_content` is replayed in DeepSeek V4
7 //! thinking-mode tool-calling turns (V4 §5.1.1; the bug that broke
8 //! v0.4.9-v0.5.1).
9 //!
10 //! # Mocking strategy
11 //!
12 //! Tests mock at the **trait boundary** (`LlmClient`), never at the `reqwest`
13 //! HTTP layer. The trait is the durable abstraction — internal HTTP plumbing
14 //! changes frequently and is not part of the public engine contract.
15 //!
16 //! # Example
17 //!
18 //! ```ignore
19 //! use crate::llm_client::mock::{MockLlmClient, canned};
20 //! use crate::llm_client::LlmClient;
21 //!
22 //! // One canned turn that emits "hello world" as two text deltas, then
23 //! // finishes with stop_reason = "end_turn".
24 //! let turn = vec![
25 //! canned::message_start("msg_1"),
26 //! canned::text_delta(0, "hello "),
27 //! canned::text_delta(0, "world"),
28 //! canned::message_stop(),
29 //! ];
30 //!
31 //! let mock = MockLlmClient::new(vec![turn]);
32 //! let stream = mock.create_message_stream(/* ... */).await.unwrap();
33 //! // ... drain the stream, assert deltas ...
34 //! assert_eq!(mock.call_count(), 1);
35 //! assert_eq!(mock.captured_requests().len(), 1);
36 //! ```
37
38 // This module ships methods + builder helpers that integration tests rely on
39 // individually. Not every helper is exercised by unit tests — that's expected
40 // (the goal is a usable mock surface for downstream tests), so we silence
41 // per-item dead-code warnings at the module level.
42 #![allow(dead_code)]
43
44 use std::collections::VecDeque;
45 use std::pin::Pin;
46 use std::sync::Mutex;
47 use std::sync::atomic::{AtomicUsize, Ordering};
48
49 use anyhow::{Result, anyhow};
50 use async_stream::try_stream;
51 use futures_util::Stream;
52
53 use crate::models::{
54 ContentBlock, MessageDelta, MessageRequest, MessageResponse, StreamEvent, Usage,
55 };
56
57 use super::{LlmClient, StreamEventBox};
58
59 /// A pre-recorded "turn" the mock will replay on the next streaming call.
60 ///
61 /// `MessageStop` does *not* need to be the final element — the mock will
62 /// auto-emit one if missing, mirroring the real client's behaviour. Likewise
63 /// the mock does not require `MessageStart` to be present.
64 pub type CannedTurn = Vec<StreamEvent>;
65
66 /// A queued mock response step.
67 pub enum FauxStep {
68 Canned(CannedTurn),
69 /// Build a canned turn from the live outgoing request.
70 ///
71 /// Tests can assert DeepSeek V4's thinking-mode tool-call invariant here:
72 /// on the assistant turn that produced the previous tool call, the next
73 /// outgoing request must still carry `reasoning_content` (represented in
74 /// this model as a [`ContentBlock::Thinking`] block). If it is missing,
75 /// DeepSeek V4 returns HTTP 400 on the follow-up turn. This guards the
76 /// [v0.4.9-v0.5.1 regression range](https://github.com/Hmbown/CodeWhale/compare/v0.4.9...v0.5.1)
77 /// where that content was dropped.
78 Factory(Box<dyn Fn(&MessageRequest) -> CannedTurn + Send + Sync>),
79 }
80
81 /// A queue-driven mock LLM client.
82 ///
83 /// The mock holds a FIFO queue of canned response turns. Each call to
84 /// [`LlmClient::create_message_stream`] dequeues the next turn and replays its
85 /// events as a stream. If the queue is exhausted, the call returns an error
86 /// — tests should ensure they push exactly as many turns as the runtime will
87 /// consume.
88 ///
89 /// The mock also captures the [`MessageRequest`] passed to every call so tests
90 /// can assert on the outgoing payload (e.g. that prior `reasoning_content` is
91 /// preserved across turns).
92 pub struct MockLlmClient {
93 canned: Mutex<VecDeque<FauxStep>>,
94 captured_requests: Mutex<Vec<MessageRequest>>,
95 calls: AtomicUsize,
96 provider_name: &'static str,
97 model: String,
98 /// If set, [`LlmClient::create_message`] returns this verbatim. Otherwise
99 /// it falls back to streaming + collection. Useful for non-streaming
100 /// compaction-style calls.
101 canned_messages: Mutex<VecDeque<MessageResponse>>,
102 }
103
104 impl MockLlmClient {
105 /// Construct a mock that will replay the given canned turns in order.
106 #[must_use]
107 pub fn new(canned: Vec<CannedTurn>) -> Self {
108 Self {
109 canned: Mutex::new(canned.into_iter().map(FauxStep::Canned).collect()),
110 captured_requests: Mutex::new(Vec::new()),
111 calls: AtomicUsize::new(0),
112 provider_name: "mock",
113 model: "mock-model".to_string(),
114 canned_messages: Mutex::new(VecDeque::new()),
115 }
116 }
117
118 /// Set the provider-name string returned by [`LlmClient::provider_name`].
119 #[must_use]
120 pub fn with_provider(mut self, name: &'static str) -> Self {
121 self.provider_name = name;
122 self
123 }
124
125 /// Set the model identifier returned by [`LlmClient::model`].
126 #[must_use]
127 pub fn with_model(mut self, model: impl Into<String>) -> Self {
128 self.model = model.into();
129 self
130 }
131
132 /// Push a canned turn onto the back of the queue.
133 pub fn push_turn(&self, turn: CannedTurn) {
134 self.canned
135 .lock()
136 .expect("MockLlmClient.canned mutex poisoned")
137 .push_back(FauxStep::Canned(turn));
138 }
139
140 /// Push a factory step onto the back of the queue.
141 ///
142 /// The closure receives the live outgoing [`MessageRequest`] before the
143 /// response stream is built, so assertions panic directly from the client
144 /// call rather than later while polling the returned stream.
145 pub fn push_factory<F>(&self, factory: F)
146 where
147 F: Fn(&MessageRequest) -> CannedTurn + Send + Sync + 'static,
148 {
149 self.canned
150 .lock()
151 .expect("MockLlmClient.canned mutex poisoned")
152 .push_back(FauxStep::Factory(Box::new(factory)));
153 }
154
155 /// Push a canned non-streaming `MessageResponse`. Consumed by
156 /// [`LlmClient::create_message`] (FIFO).
157 pub fn push_message_response(&self, response: MessageResponse) {
158 self.canned_messages
159 .lock()
160 .expect("MockLlmClient.canned_messages mutex poisoned")
161 .push_back(response);
162 }
163
164 /// Number of completed calls to either `create_message` or
165 /// `create_message_stream`.
166 #[must_use]
167 pub fn call_count(&self) -> usize {
168 self.calls.load(Ordering::SeqCst)
169 }
170
171 /// Number of canned turns still queued.
172 #[must_use]
173 pub fn remaining_turns(&self) -> usize {
174 self.canned
175 .lock()
176 .expect("MockLlmClient.canned mutex poisoned")
177 .len()
178 }
179
180 /// Snapshot of every request the mock has been asked to handle, in order.
181 #[must_use]
182 pub fn captured_requests(&self) -> Vec<MessageRequest> {
183 self.captured_requests
184 .lock()
185 .expect("MockLlmClient.captured_requests mutex poisoned")
186 .clone()
187 }
188
189 /// Convenience: return the most recently captured request, or `None` if
190 /// the mock has not been called yet.
191 #[must_use]
192 pub fn last_request(&self) -> Option<MessageRequest> {
193 self.captured_requests
194 .lock()
195 .expect("MockLlmClient.captured_requests mutex poisoned")
196 .last()
197 .cloned()
198 }
199
200 fn record_request(&self, request: &MessageRequest) {
201 self.captured_requests
202 .lock()
203 .expect("MockLlmClient.captured_requests mutex poisoned")
204 .push(request.clone());
205 self.calls.fetch_add(1, Ordering::SeqCst);
206 }
207
208 fn pop_step(&self) -> Option<FauxStep> {
209 self.canned
210 .lock()
211 .expect("MockLlmClient.canned mutex poisoned")
212 .pop_front()
213 }
214
215 fn turn_from_step(&self, step: FauxStep, request: &MessageRequest) -> CannedTurn {
216 match step {
217 FauxStep::Canned(turn) => turn,
218 FauxStep::Factory(factory) => factory(request),
219 }
220 }
221
222 fn pop_message(&self) -> Option<MessageResponse> {
223 self.canned_messages
224 .lock()
225 .expect("MockLlmClient.canned_messages mutex poisoned")
226 .pop_front()
227 }
228 }
229
230 impl LlmClient for MockLlmClient {
231 fn provider_name(&self) -> &'static str {
232 self.provider_name
233 }
234
235 fn model(&self) -> &str {
236 &self.model
237 }
238
239 async fn create_message(&self, request: MessageRequest) -> Result<MessageResponse> {
240 self.record_request(&request);
241
242 if let Some(canned) = self.pop_message() {
243 return Ok(canned);
244 }
245
246 // Fallback: synthesize a MessageResponse from the next streaming turn.
247 let Some(step) = self.pop_step() else {
248 return Err(anyhow!(
249 "MockLlmClient: create_message called but no canned response queued (request #{})",
250 self.calls.load(Ordering::SeqCst)
251 ));
252 };
253
254 let turn = self.turn_from_step(step, &request);
255 Ok(synthesize_message_response(turn, &self.model))
256 }
257
258 async fn create_message_stream(&self, request: MessageRequest) -> Result<StreamEventBox> {
259 self.record_request(&request);
260
261 let Some(step) = self.pop_step() else {
262 return Err(anyhow!(
263 "MockLlmClient: create_message_stream called but no canned turn queued (call #{})",
264 self.calls.load(Ordering::SeqCst)
265 ));
266 };
267
268 let turn = self.turn_from_step(step, &request);
269 Ok(stream_from_canned(turn))
270 }
271
272 async fn health_check(&self) -> Result<bool> {
273 Ok(true)
274 }
275 }
276
277 /// Wrap a canned event vector as a stream that yields each event in order and
278 /// auto-appends `MessageStop` if the trailing event is not already one.
279 fn stream_from_canned(turn: CannedTurn) -> StreamEventBox {
280 let s = try_stream! {
281 let has_stop = matches!(turn.last(), Some(StreamEvent::MessageStop));
282 for ev in turn {
283 yield ev;
284 }
285 if !has_stop {
286 yield StreamEvent::MessageStop;
287 }
288 };
289 Box::pin(s) as Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + 'static>>
290 }
291
292 /// Best-effort: collapse a streaming turn into a non-streaming
293 /// `MessageResponse` by concatenating text deltas. Used only as a fallback
294 /// when callers `create_message` without a queued `MessageResponse`.
295 fn synthesize_message_response(turn: CannedTurn, model: &str) -> MessageResponse {
296 use crate::models::Delta;
297
298 let mut text = String::new();
299 let mut stop_reason: Option<String> = None;
300
301 for ev in turn {
302 match ev {
303 StreamEvent::ContentBlockDelta {
304 delta: Delta::TextDelta { text: t },
305 ..
306 } => text.push_str(&t),
307 StreamEvent::MessageDelta {
308 delta: MessageDelta {
309 stop_reason: sr, ..
310 },
311 ..
312 } => stop_reason = sr,
313 _ => {}
314 }
315 }
316
317 MessageResponse {
318 id: "mock_msg".to_string(),
319 r#type: "message".to_string(),
320 role: "assistant".to_string(),
321 content: vec![ContentBlock::Text {
322 text,
323 cache_control: None,
324 }],
325 model: model.to_string(),
326 stop_reason: stop_reason.or_else(|| Some("end_turn".to_string())),
327 stop_sequence: None,
328 container: None,
329 usage: Usage::default(),
330 }
331 }
332
333 /// Builders for common canned-event patterns. Re-exported so tests can build
334 /// realistic streams without wiring `StreamEvent` shapes by hand.
335 pub mod canned {
336 use serde_json::Value;
337
338 use crate::models::{
339 ContentBlockStart, Delta, MessageDelta, MessageResponse, StreamEvent, Usage,
340 };
341
342 /// `MessageStart` event with a synthetic message envelope.
343 #[must_use]
344 pub fn message_start(id: &str) -> StreamEvent {
345 StreamEvent::MessageStart {
346 message: MessageResponse {
347 id: id.to_string(),
348 r#type: "message".to_string(),
349 role: "assistant".to_string(),
350 content: vec![],
351 model: "mock-model".to_string(),
352 stop_reason: None,
353 stop_sequence: None,
354 container: None,
355 usage: Usage::default(),
356 },
357 }
358 }
359
360 /// Open a text content block at `index`.
361 #[must_use]
362 pub fn text_block_start(index: u32) -> StreamEvent {
363 StreamEvent::ContentBlockStart {
364 index,
365 content_block: ContentBlockStart::Text {
366 text: String::new(),
367 },
368 }
369 }
370
371 /// Append `text` to the content block at `index`.
372 #[must_use]
373 pub fn text_delta(index: u32, text: &str) -> StreamEvent {
374 StreamEvent::ContentBlockDelta {
375 index,
376 delta: Delta::TextDelta {
377 text: text.to_string(),
378 },
379 }
380 }
381
382 /// Append a thinking-content delta at `index`.
383 #[must_use]
384 pub fn thinking_delta(index: u32, thinking: &str) -> StreamEvent {
385 StreamEvent::ContentBlockDelta {
386 index,
387 delta: Delta::ThinkingDelta {
388 thinking: thinking.to_string(),
389 },
390 }
391 }
392
393 /// Open a tool_use content block at `index`.
394 #[must_use]
395 pub fn tool_use_block_start(index: u32, id: &str, name: &str) -> StreamEvent {
396 StreamEvent::ContentBlockStart {
397 index,
398 content_block: ContentBlockStart::ToolUse {
399 id: id.to_string(),
400 name: name.to_string(),
401 input: Value::Null,
402 caller: None,
403 },
404 }
405 }
406
407 /// Stream partial JSON for a tool's input arguments.
408 #[must_use]
409 pub fn tool_input_delta(index: u32, partial_json: &str) -> StreamEvent {
410 StreamEvent::ContentBlockDelta {
411 index,
412 delta: Delta::InputJsonDelta {
413 partial_json: partial_json.to_string(),
414 },
415 }
416 }
417
418 /// Close the content block at `index`.
419 #[must_use]
420 pub fn block_stop(index: u32) -> StreamEvent {
421 StreamEvent::ContentBlockStop { index }
422 }
423
424 /// Emit a `message_delta` carrying `stop_reason` and optional `usage`.
425 #[must_use]
426 pub fn message_delta(stop_reason: &str, usage: Option<Usage>) -> StreamEvent {
427 StreamEvent::MessageDelta {
428 delta: MessageDelta {
429 stop_reason: Some(stop_reason.to_string()),
430 stop_sequence: None,
431 },
432 usage,
433 }
434 }
435
436 /// Final `message_stop` sentinel.
437 #[must_use]
438 pub fn message_stop() -> StreamEvent {
439 StreamEvent::MessageStop
440 }
441
442 /// Convenience: a complete "assistant emits this text" turn ending with
443 /// `stop_reason = "end_turn"`.
444 #[must_use]
445 pub fn simple_text_turn(text: &str) -> Vec<StreamEvent> {
446 vec![
447 message_start("mock_msg_1"),
448 text_block_start(0),
449 text_delta(0, text),
450 block_stop(0),
451 message_delta("end_turn", None),
452 message_stop(),
453 ]
454 }
455
456 /// Convenience: a turn that emits one assistant tool_call and stops.
457 #[must_use]
458 pub fn tool_call_turn(call_id: &str, tool_name: &str, args_json: &str) -> Vec<StreamEvent> {
459 vec![
460 message_start("mock_msg_tool"),
461 tool_use_block_start(0, call_id, tool_name),
462 tool_input_delta(0, args_json),
463 block_stop(0),
464 message_delta("tool_use", None),
465 message_stop(),
466 ]
467 }
468 }
469
470 // === Tests ===
471
472 #[cfg(test)]
473 mod tests {
474 use futures_util::StreamExt;
475
476 use super::*;
477 use crate::llm_client::LlmClient;
478 use crate::models::{Delta, Message, MessageRequest, StreamEvent};
479
480 fn empty_request() -> MessageRequest {
481 MessageRequest {
482 model: "mock-model".to_string(),
483 messages: vec![Message {
484 role: "user".to_string(),
485 content: vec![],
486 }],
487 max_tokens: 1024,
488 system: None,
489 tools: None,
490 tool_choice: None,
491 metadata: None,
492 thinking: None,
493 reasoning_effort: None,
494 stream: Some(true),
495 temperature: None,
496 top_p: None,
497 }
498 }
499
500 #[tokio::test]
501 async fn replays_canned_turn_via_stream() {
502 let mock = MockLlmClient::new(vec![canned::simple_text_turn("hello world")]);
503
504 let mut stream = mock
505 .create_message_stream(empty_request())
506 .await
507 .expect("stream should open");
508
509 let mut text = String::new();
510 let mut saw_stop = false;
511 while let Some(ev) = stream.next().await {
512 match ev.expect("event") {
513 StreamEvent::ContentBlockDelta {
514 delta: Delta::TextDelta { text: t },
515 ..
516 } => text.push_str(&t),
517 StreamEvent::MessageStop => {
518 saw_stop = true;
519 break;
520 }
521 _ => {}
522 }
523 }
524
525 assert_eq!(text, "hello world");
526 assert!(saw_stop);
527 assert_eq!(mock.call_count(), 1);
528 assert_eq!(mock.captured_requests().len(), 1);
529 assert_eq!(mock.remaining_turns(), 0);
530 }
531
532 #[tokio::test]
533 async fn errors_when_queue_exhausted() {
534 let mock = MockLlmClient::new(Vec::new());
535 let result = mock.create_message_stream(empty_request()).await;
536 match result {
537 Ok(_) => panic!("should error on empty queue"),
538 Err(err) => assert!(format!("{err}").contains("no canned")),
539 }
540 }
541
542 #[tokio::test]
543 async fn captures_request_payload_for_assertions() {
544 let mock = MockLlmClient::new(vec![canned::simple_text_turn("ok")]);
545 let mut req = empty_request();
546 req.temperature = Some(0.42);
547 let _ = mock.create_message_stream(req).await.unwrap();
548
549 let captured = mock.last_request().expect("should have captured");
550 assert_eq!(captured.temperature, Some(0.42));
551 }
552
553 #[tokio::test]
554 async fn stream_auto_appends_message_stop() {
555 // Queue a turn missing MessageStop — mock should append one.
556 let turn = vec![canned::text_block_start(0), canned::text_delta(0, "x")];
557 let mock = MockLlmClient::new(vec![turn]);
558
559 let mut stream = mock.create_message_stream(empty_request()).await.unwrap();
560 let mut saw_stop = false;
561 while let Some(ev) = stream.next().await {
562 if matches!(ev.expect("event"), StreamEvent::MessageStop) {
563 saw_stop = true;
564 }
565 }
566 assert!(saw_stop, "auto MessageStop missing");
567 }
568
569 #[tokio::test]
570 async fn create_message_uses_canned_message_response_first() {
571 let mock = MockLlmClient::new(vec![canned::simple_text_turn("from stream")]);
572 mock.push_message_response(MessageResponse {
573 id: "preset".to_string(),
574 r#type: "message".to_string(),
575 role: "assistant".to_string(),
576 content: vec![ContentBlock::Text {
577 text: "from preset".to_string(),
578 cache_control: None,
579 }],
580 model: "mock-model".to_string(),
581 stop_reason: Some("end_turn".to_string()),
582 stop_sequence: None,
583 container: None,
584 usage: Usage::default(),
585 });
586
587 let resp = mock.create_message(empty_request()).await.unwrap();
588 assert_eq!(resp.id, "preset");
589 }
590
591 #[tokio::test]
592 async fn create_message_synthesizes_from_streaming_turn_when_no_message_queued() {
593 let mock = MockLlmClient::new(vec![canned::simple_text_turn("synthesized")]);
594 let resp = mock.create_message(empty_request()).await.unwrap();
595 let text = match &resp.content[0] {
596 ContentBlock::Text { text, .. } => text.clone(),
597 _ => panic!("expected text"),
598 };
599 assert_eq!(text, "synthesized");
600 assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
601 }
602
603 #[tokio::test]
604 async fn create_message_synthesizes_from_factory_turn() {
605 let mock = MockLlmClient::new(Vec::new());
606 mock.push_factory(|request| {
607 assert_eq!(request.model, "mock-model");
608 canned::simple_text_turn("from factory")
609 });
610
611 let resp = mock.create_message(empty_request()).await.unwrap();
612 let text = match &resp.content[0] {
613 ContentBlock::Text { text, .. } => text.clone(),
614 _ => panic!("expected text"),
615 };
616 assert_eq!(text, "from factory");
617 }
618
619 #[tokio::test]
620 async fn provider_and_model_are_overridable() {
621 let mock = MockLlmClient::new(vec![canned::simple_text_turn("x")])
622 .with_provider("test-provider")
623 .with_model("test-model");
624 assert_eq!(mock.provider_name(), "test-provider");
625 assert_eq!(mock.model(), "test-model");
626 }
627
628 #[tokio::test]
629 async fn tool_call_turn_serializes_correctly() {
630 let mock = MockLlmClient::new(vec![canned::tool_call_turn(
631 "call_1",
632 "list_dir",
633 r#"{"path":"/tmp"}"#,
634 )]);
635 let mut stream = mock.create_message_stream(empty_request()).await.unwrap();
636
637 let mut saw_tool_use = false;
638 let mut json_seen = String::new();
639 while let Some(ev) = stream.next().await {
640 match ev.unwrap() {
641 StreamEvent::ContentBlockStart { content_block, .. } => {
642 use crate::models::ContentBlockStart;
643 if let ContentBlockStart::ToolUse { name, .. } = content_block {
644 assert_eq!(name, "list_dir");
645 saw_tool_use = true;
646 }
647 }
648 StreamEvent::ContentBlockDelta {
649 delta: Delta::InputJsonDelta { partial_json },
650 ..
651 } => json_seen.push_str(&partial_json),
652 _ => {}
653 }
654 }
655 assert!(saw_tool_use, "expected tool_use start event");
656 assert!(json_seen.contains("/tmp"));
657 }
658
659 #[tokio::test]
660 async fn multiple_turns_consumed_in_order() {
661 let mock = MockLlmClient::new(vec![
662 canned::simple_text_turn("turn-one"),
663 canned::simple_text_turn("turn-two"),
664 ]);
665 for expected in ["turn-one", "turn-two"] {
666 let mut stream = mock.create_message_stream(empty_request()).await.unwrap();
667 let mut text = String::new();
668 while let Some(ev) = stream.next().await {
669 if let StreamEvent::ContentBlockDelta {
670 delta: Delta::TextDelta { text: t },
671 ..
672 } = ev.unwrap()
673 {
674 text.push_str(&t);
675 }
676 }
677 assert_eq!(text, expected);
678 }
679 assert_eq!(mock.call_count(), 2);
680 assert_eq!(mock.remaining_turns(), 0);
681 }
682 }
683
683 lines RUST