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