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