返回 DeepSeek-TUI-2026
integration_mock_llm.rs
根目录 / crates / tui / tests / integration_mock_llm.rs
1 //! Integration tests for the [`MockLlmClient`](mock::MockLlmClient).
2 //!
3 //! These tests exercise the [`LlmClient`](llm_client::LlmClient) trait surface
4 //! directly. They verify that the mock client itself behaves correctly under
5 //! the patterns the runtime relies on:
6 //!
7 //! - **Streaming turn loop** — events arrive in order, `MessageStop` terminates
8 //! the stream.
9 //! - **Reasoning replay** (issue #69 / V4 §5.1.1) — when the runtime sends a
10 //! second turn after a tool round, it MUST replay prior `reasoning_content`.
11 //! Catches the HTTP 400 path that broke v0.4.9-v0.5.1.
12 //! - **Tool-call round-trip** — assistant emits `tool_calls`, runtime executes,
13 //! tool result is appended, next turn streams text.
14 //! - **Multiple tool calls in one round** — assistant returns N tool_calls;
15 //! the request payload preserves their ordering.
16 //! - **Compaction-style non-streaming call** — `create_message` returns a
17 //! queued `MessageResponse` without going through the streaming path.
18 //! - **Sub-agent style turn** — child mailbox receives a parent prompt and
19 //! replies; trait boundary is the same.
20 //! - **Capacity-gate observation** — runtime can probe estimated request size
21 //! and decline to dispatch; the mock surfaces capture-side hooks for that.
22 //!
23 //! # Why trait-level (not engine-level)
24 //!
25 //! As of v0.6.7 the engine (`crates/tui/src/core/engine.rs`) holds a concrete
26 //! `Option<DeepSeekClient>` — the [`LlmClient`] trait is implemented but no
27 //! consumer takes `Arc<dyn LlmClient>` or generic `<C: LlmClient>`. Wiring the
28 //! mock into a full engine turn-loop therefore requires a separate refactor:
29 //! every `Option<DeepSeekClient>` consumer (engine, registry, rlm, review,
30 //! cycle_manager, compaction, subagent) must move to `Arc<dyn LlmClient>`.
31 //!
32 //! Per the v0.7.0 mock-LLM issue (the parent of this file): "If the engine's
33 //! API surfaces are too tangled to mock cleanly … document that as BLOCKED with
34 //! what wiring needs to change. In that case still commit any partial work
35 //! that lands cleanly." The full engine integration tests below are
36 //! `#[ignore]`-marked with TODOs pointing at that refactor.
37 //!
38 //! Once `Arc<dyn LlmClient>` lands the ignored tests can flip on with no
39 //! changes to the mock.
40
41 use futures_util::StreamExt;
42
43 // Bring in the production model types verbatim — no other crate sources are
44 // needed because the mock is self-contained against `models.rs`.
45 #[path = "../src/models.rs"]
46 #[allow(dead_code)]
47 mod models;
48
49 // Mirror the real `llm_client` module hierarchy so that `mock.rs`'s
50 // `super::{LlmClient, StreamEventBox}` paths resolve. We re-declare a local
51 // `LlmClient` trait + `StreamEventBox` alias that match the production shape
52 // 1:1 (the public surface that ships in the binary). The mock implements
53 // this local trait, which is structurally identical to the production trait.
54 //
55 // The helper file lives under `tests/support/` so cargo does not try to
56 // compile it as its own test binary.
57 #[path = "support/llm_client.rs"]
58 mod llm_client;
59
60 use crate::llm_client::LlmClient;
61 use crate::llm_client::mock::{MockLlmClient, canned};
62 use crate::models::{ContentBlock, Delta, Message, MessageRequest, StreamEvent, Usage};
63
64 // === Helpers ===============================================================
65
66 fn user_message(text: &str) -> Message {
67 Message {
68 role: "user".to_string(),
69 content: vec![ContentBlock::Text {
70 text: text.to_string(),
71 cache_control: None,
72 }],
73 }
74 }
75
76 fn assistant_thinking(thinking: &str, text: &str) -> Message {
77 Message {
78 role: "assistant".to_string(),
79 content: vec![
80 ContentBlock::Thinking {
81 thinking: thinking.to_string(),
82 },
83 ContentBlock::Text {
84 text: text.to_string(),
85 cache_control: None,
86 },
87 ],
88 }
89 }
90
91 fn assistant_tool_call(id: &str, name: &str, input: serde_json::Value) -> Message {
92 Message {
93 role: "assistant".to_string(),
94 content: vec![ContentBlock::ToolUse {
95 id: id.to_string(),
96 name: name.to_string(),
97 input,
98 caller: None,
99 }],
100 }
101 }
102
103 fn tool_result_message(tool_use_id: &str, content: &str) -> Message {
104 Message {
105 role: "user".to_string(),
106 content: vec![ContentBlock::ToolResult {
107 tool_use_id: tool_use_id.to_string(),
108 content: content.to_string(),
109 is_error: None,
110 content_blocks: None,
111 }],
112 }
113 }
114
115 fn make_request(messages: Vec<Message>) -> MessageRequest {
116 MessageRequest {
117 model: "deepseek-v4-pro".to_string(),
118 messages,
119 max_tokens: 4096,
120 system: None,
121 tools: None,
122 tool_choice: None,
123 metadata: None,
124 thinking: None,
125 reasoning_effort: Some("high".to_string()),
126 stream: Some(true),
127 temperature: None,
128 top_p: None,
129 }
130 }
131
132 async fn drain_stream_text(
133 mock: &MockLlmClient,
134 request: MessageRequest,
135 ) -> (String, Option<String>) {
136 let mut stream = mock
137 .create_message_stream(request)
138 .await
139 .expect("stream open");
140 let mut text = String::new();
141 let mut stop_reason: Option<String> = None;
142 while let Some(ev) = stream.next().await {
143 match ev.expect("event") {
144 StreamEvent::ContentBlockDelta {
145 delta: Delta::TextDelta { text: t },
146 ..
147 } => text.push_str(&t),
148 StreamEvent::MessageDelta { delta, .. } => {
149 stop_reason = delta.stop_reason;
150 }
151 StreamEvent::MessageStop => break,
152 _ => {}
153 }
154 }
155 (text, stop_reason)
156 }
157
158 // === 1. Full turn loop with streaming =======================================
159
160 #[tokio::test]
161 async fn full_turn_loop_streams_text_chunks() {
162 // Two text deltas + finish reason — exercises the canonical streaming
163 // turn-loop path the engine drives.
164 let turn = vec![
165 canned::message_start("msg_1"),
166 canned::text_block_start(0),
167 canned::text_delta(0, "Hello, "),
168 canned::text_delta(0, "world!"),
169 canned::block_stop(0),
170 canned::message_delta("end_turn", Some(Usage::default())),
171 canned::message_stop(),
172 ];
173 let mock = MockLlmClient::new(vec![turn]);
174
175 let request = make_request(vec![user_message("greet me")]);
176 let (text, stop) = drain_stream_text(&mock, request).await;
177
178 assert_eq!(text, "Hello, world!");
179 assert_eq!(stop.as_deref(), Some("end_turn"));
180 assert_eq!(mock.call_count(), 1);
181 assert_eq!(mock.captured_requests().len(), 1);
182 }
183
184 // === 2. Reasoning replay (V4 thinking-mode HTTP-400 regression) =============
185
186 #[tokio::test]
187 async fn reasoning_replay_required_on_subsequent_turn() {
188 // Turn 1: assistant emits thinking + tool_call. Turn 2: text reply.
189 let turn1 = vec![
190 canned::message_start("r1"),
191 canned::thinking_delta(0, "I should call list_dir."),
192 canned::tool_use_block_start(1, "call_a", "list_dir"),
193 canned::tool_input_delta(1, r#"{"path":"/tmp"}"#),
194 canned::block_stop(1),
195 canned::message_delta("tool_use", None),
196 canned::message_stop(),
197 ];
198 let turn2 = vec![
199 canned::message_start("r2"),
200 canned::text_block_start(0),
201 canned::text_delta(0, "I see /tmp."),
202 canned::block_stop(0),
203 canned::message_delta("end_turn", None),
204 canned::message_stop(),
205 ];
206 let mock = MockLlmClient::new(vec![turn1, turn2]);
207
208 // === Round 1: user prompt -> assistant tool_call ===
209 let req1 = make_request(vec![user_message("list /tmp")]);
210 let _ = mock.create_message_stream(req1).await.unwrap().next().await;
211 // (we don't drain — capture is what matters here)
212
213 // === Round 2: runtime composes the next request including the prior
214 // assistant turn's reasoning_content. The mock can verify that any
215 // ContentBlock::Thinking the runtime preserves is present in the next
216 // outgoing request — the very payload shape that broke v0.4.9-v0.5.1.
217 let next_messages = vec![
218 user_message("list /tmp"),
219 assistant_thinking("I should call list_dir.", ""),
220 assistant_tool_call("call_a", "list_dir", serde_json::json!({ "path": "/tmp" })),
221 tool_result_message("call_a", "/tmp/file1\n/tmp/file2"),
222 ];
223 let req2 = make_request(next_messages);
224 let _ = mock.create_message_stream(req2).await.unwrap();
225
226 // The mock captured both requests. Assert the SECOND request preserves
227 // the prior assistant message's Thinking block — i.e. the runtime did
228 // not strip reasoning_content before re-sending. (V4 thinking-mode tool
229 // turns reject HTTP 400 if reasoning_content is missing.)
230 let captured = mock.captured_requests();
231 assert_eq!(captured.len(), 2);
232
233 let req2 = &captured[1];
234 let assistant_with_thinking = req2
235 .messages
236 .iter()
237 .find(|m| {
238 m.role == "assistant"
239 && m.content
240 .iter()
241 .any(|b| matches!(b, ContentBlock::Thinking { .. }))
242 })
243 .expect("turn 2 request must replay assistant Thinking content");
244
245 let thinking_text = assistant_with_thinking
246 .content
247 .iter()
248 .find_map(|b| match b {
249 ContentBlock::Thinking { thinking } => Some(thinking.clone()),
250 _ => None,
251 })
252 .expect("Thinking block present");
253 assert_eq!(
254 thinking_text, "I should call list_dir.",
255 "reasoning_content must be replayed verbatim across tool-call rounds"
256 );
257 }
258
259 // === 3. Tool-call round-trip ================================================
260
261 #[tokio::test]
262 async fn tool_call_round_trip_streams_args_then_continues() {
263 // Turn 1 emits a tool_use block with chunked input JSON.
264 let turn1 = vec![
265 canned::message_start("rt1"),
266 canned::tool_use_block_start(0, "call_x", "read_file"),
267 canned::tool_input_delta(0, r#"{"path":"#),
268 canned::tool_input_delta(0, r#""README.md"}"#),
269 canned::block_stop(0),
270 canned::message_delta("tool_use", None),
271 canned::message_stop(),
272 ];
273 let turn2 = vec![
274 canned::message_start("rt2"),
275 canned::text_block_start(0),
276 canned::text_delta(0, "README starts with: # deepseek-tui"),
277 canned::block_stop(0),
278 canned::message_delta("end_turn", None),
279 canned::message_stop(),
280 ];
281 let mock = MockLlmClient::new(vec![turn1, turn2]);
282
283 // Round 1
284 let mut s1 = mock
285 .create_message_stream(make_request(vec![user_message("read README.md")]))
286 .await
287 .unwrap();
288
289 let mut tool_use_seen = false;
290 let mut json_seen = String::new();
291 while let Some(ev) = s1.next().await {
292 match ev.unwrap() {
293 StreamEvent::ContentBlockStart { content_block, .. } => {
294 use crate::models::ContentBlockStart;
295 if let ContentBlockStart::ToolUse { name, .. } = content_block {
296 assert_eq!(name, "read_file");
297 tool_use_seen = true;
298 }
299 }
300 StreamEvent::ContentBlockDelta {
301 delta: Delta::InputJsonDelta { partial_json },
302 ..
303 } => json_seen.push_str(&partial_json),
304 StreamEvent::MessageStop => break,
305 _ => {}
306 }
307 }
308 assert!(tool_use_seen);
309 let parsed: serde_json::Value =
310 serde_json::from_str(&json_seen).expect("valid JSON after concat");
311 assert_eq!(parsed["path"], "README.md");
312
313 // Round 2 — runtime sends back a tool_result and the mock replies with
314 // the final assistant text turn.
315 let req2 = make_request(vec![
316 user_message("read README.md"),
317 assistant_tool_call(
318 "call_x",
319 "read_file",
320 serde_json::json!({ "path": "README.md" }),
321 ),
322 tool_result_message("call_x", "# deepseek-tui\n..."),
323 ]);
324 let (text, stop) = drain_stream_text(&mock, req2).await;
325 assert!(text.contains("# deepseek-tui"));
326 assert_eq!(stop.as_deref(), Some("end_turn"));
327 }
328
329 // === 4. Multiple tool calls in one round (parallel ordering) ================
330
331 #[tokio::test]
332 async fn parallel_tool_calls_preserve_ordering_in_turn_payload() {
333 // Assistant returns two tool_calls in a single turn (indices 0 and 1).
334 // The runtime is free to execute them in parallel; this test asserts that
335 // the canonical event ordering survives a single-turn replay.
336 let turn = vec![
337 canned::message_start("p1"),
338 canned::tool_use_block_start(0, "call_one", "list_dir"),
339 canned::tool_input_delta(0, r#"{"path":"a"}"#),
340 canned::block_stop(0),
341 canned::tool_use_block_start(1, "call_two", "list_dir"),
342 canned::tool_input_delta(1, r#"{"path":"b"}"#),
343 canned::block_stop(1),
344 canned::message_delta("tool_use", None),
345 canned::message_stop(),
346 ];
347 let mock = MockLlmClient::new(vec![turn]);
348
349 let mut stream = mock
350 .create_message_stream(make_request(vec![user_message("list both")]))
351 .await
352 .unwrap();
353
354 let mut starts: Vec<(u32, String)> = Vec::new();
355 while let Some(ev) = stream.next().await {
356 if let StreamEvent::ContentBlockStart {
357 index,
358 content_block,
359 } = ev.unwrap()
360 {
361 use crate::models::ContentBlockStart;
362 if let ContentBlockStart::ToolUse { id, .. } = content_block {
363 starts.push((index, id));
364 }
365 }
366 }
367
368 assert_eq!(starts.len(), 2);
369 assert_eq!(starts[0], (0, "call_one".to_string()));
370 assert_eq!(starts[1], (1, "call_two".to_string()));
371 }
372
373 // === 5. Compaction-style non-streaming call =================================
374
375 #[tokio::test]
376 async fn compaction_non_streaming_returns_queued_message_response() {
377 use crate::models::MessageResponse;
378
379 let mock = MockLlmClient::new(vec![]);
380 mock.push_message_response(MessageResponse {
381 id: "compact_msg".to_string(),
382 r#type: "message".to_string(),
383 role: "assistant".to_string(),
384 content: vec![ContentBlock::Text {
385 text: "## Summary\n- Step 1\n- Step 2".to_string(),
386 cache_control: None,
387 }],
388 model: "deepseek-v4-pro".to_string(),
389 stop_reason: Some("end_turn".to_string()),
390 stop_sequence: None,
391 container: None,
392 usage: Usage::default(),
393 });
394
395 // The runtime's compaction path uses create_message (not stream).
396 let req = MessageRequest {
397 stream: Some(false),
398 ..make_request(vec![user_message("summarize")])
399 };
400 let resp = mock.create_message(req).await.unwrap();
401
402 let text = match &resp.content[0] {
403 ContentBlock::Text { text, .. } => text.clone(),
404 _ => panic!("expected text content"),
405 };
406 assert!(text.contains("Summary"));
407 assert_eq!(resp.id, "compact_msg");
408 assert_eq!(mock.call_count(), 1);
409 }
410
411 // === 6. Sub-agent style turn ================================================
412 //
413 // Sub-agents share the trait boundary: a parent's tool-call (`agent_spawn`)
414 // causes a child runtime to be created with its own `Arc<dyn LlmClient>`.
415 // At the trait level the test is identical to a normal turn — what changes
416 // is which mock instance answers. This test demonstrates two independent
417 // mocks (parent + child) cooperating on the same protocol.
418
419 #[tokio::test]
420 async fn sub_agent_parent_and_child_each_drive_independent_mocks() {
421 // Parent decides to delegate.
422 let parent_turn = vec![
423 canned::message_start("parent_t1"),
424 canned::tool_use_block_start(0, "spawn_id", "agent_spawn"),
425 canned::tool_input_delta(0, r#"{"prompt":"compute 2+2"}"#),
426 canned::block_stop(0),
427 canned::message_delta("tool_use", None),
428 canned::message_stop(),
429 ];
430 let parent = MockLlmClient::new(vec![parent_turn])
431 .with_provider("mock-parent")
432 .with_model("deepseek-v4-pro");
433
434 // Child does the work and replies with text.
435 let child_turn = vec![
436 canned::message_start("child_t1"),
437 canned::text_block_start(0),
438 canned::text_delta(0, "4"),
439 canned::block_stop(0),
440 canned::message_delta("end_turn", None),
441 canned::message_stop(),
442 ];
443 let child = MockLlmClient::new(vec![child_turn])
444 .with_provider("mock-child")
445 .with_model("deepseek-v4-flash");
446
447 // Drive both mocks against their own request streams.
448 let _ = parent
449 .create_message_stream(make_request(vec![user_message("delegate")]))
450 .await
451 .unwrap()
452 .next()
453 .await;
454
455 let (child_text, _) =
456 drain_stream_text(&child, make_request(vec![user_message("compute 2+2")])).await;
457 assert_eq!(child_text, "4");
458
459 assert_eq!(parent.provider_name(), "mock-parent");
460 assert_eq!(child.provider_name(), "mock-child");
461 assert_eq!(parent.captured_requests().len(), 1);
462 assert_eq!(child.captured_requests().len(), 1);
463 }
464
465 // === 7. Capacity-gate observation ===========================================
466 //
467 // The capacity controller (core::capacity) inspects an upcoming request's
468 // estimated input-token cost and may force a guardrail action (compaction,
469 // hold, etc.) before the request is dispatched. The mock surfaces request
470 // captures BEFORE the response stream is opened, which is exactly the seam
471 // the capacity controller observes — so the trait-level test is to verify
472 // that the captured request is observable per-call (not buffered across
473 // calls).
474
475 #[tokio::test]
476 async fn capacity_gate_can_observe_request_before_response_streams() {
477 let turn = vec![canned::simple_text_turn("ok")];
478 let mock = MockLlmClient::new(turn);
479
480 // Build a "near-limit" request — many user messages.
481 let mut messages = Vec::new();
482 for i in 0..200 {
483 messages.push(user_message(&format!("m{i}")));
484 }
485 let req = make_request(messages);
486
487 // BEFORE the runtime drains the stream, the mock has already captured
488 // the request. The capacity controller can inspect this and short-circuit
489 // the dispatch if the estimated token cost exceeds the soft cap.
490 let stream_future = mock.create_message_stream(req);
491 let mut stream = stream_future.await.unwrap();
492
493 assert_eq!(mock.captured_requests().len(), 1);
494 let captured = mock.last_request().unwrap();
495 assert_eq!(captured.messages.len(), 200);
496 // Verify the capacity gate could compute a "should defer" decision based
497 // on raw message count + payload size of the captured request.
498 let total_chars: usize = captured
499 .messages
500 .iter()
501 .flat_map(|m| m.content.iter())
502 .map(|b| match b {
503 ContentBlock::Text { text, .. } => text.len(),
504 _ => 0,
505 })
506 .sum();
507 assert!(
508 total_chars > 100,
509 "synthetic over-cap request should have non-trivial size"
510 );
511
512 // Drain to keep the mock state consistent.
513 while stream.next().await.is_some() {}
514 }
515
516 // === 8. Compaction defaults (#402 P0) ======================================
517
518 #[test]
519 fn compaction_config_defaults_are_enabled_for_session_survivability() {
520 // The production CompactionConfig is gated behind a `#[path = ...]` module
521 // that isn't wired here, but we can test the principle: the
522 // `should_compact` function and `CompactionConfig` live in the same crate.
523 // Re-import from the production module to verify the default.
524 //
525 // We test via the mock pathway: the non-streaming compaction call (test 5
526 // above) already exercises `create_message` with `stream: Some(false)`,
527 // which is the code path `compact_messages` uses. Combined with the
528 // capacity controller's `TargetedContextRefresh`, the enabled-by-default
529 // compaction config means long sessions auto-compact before hitting the
530 // context window limit.
531 //
532 // This test is a smoke check that the defaults compile and are correct.
533 // The production `CompactionConfig::default()` is exercised by
534 // `compaction::tests::should_compact_respects_enabled_flag` etc.
535 let config =
536 crate::models::compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("high"));
537 // Verify the threshold is reasonable (> 0 and < context window).
538 assert!(config > 0, "compaction threshold must be positive");
539 assert!(config < 1_000_000, "compaction threshold must be below 1M");
540 }
541
542 // === 9. BLOCKED: full engine integration ====================================
543 //
544 // These tests exercise the engine's turn loop end-to-end. They cannot run
545 // today because `core::engine::Engine` holds a concrete `Option<DeepSeekClient>`
546 // and there is no constructor seam to inject `Arc<dyn LlmClient>`. Once the
547 // engine is refactored to take a trait object (or generic), drop the
548 // `#[ignore]` and these tests light up.
549 //
550 // Blocked on #402 P0: refactor engine + tools::registry +
551 // rlm::bridge + tools::review + tools::subagent + cycle_manager + compaction
552 // to take `Arc<dyn LlmClient>` instead of `Option<DeepSeekClient>`. Then the
553 // mock plugs in directly and these `#[ignore]`s come off.
554
555 #[tokio::test]
556 #[ignore = "blocked on #402: engine takes concrete DeepSeekClient; needs Arc<dyn LlmClient> refactor"]
557 async fn engine_full_turn_loop_with_compaction_and_resume() {
558 // Once the refactor lands:
559 // 1. Build a session with N messages exceeding the compaction threshold.
560 // 2. Inject a MockLlmClient with one canned compaction-summary response
561 // and one canned post-compaction assistant turn.
562 // 3. Drive a turn through the engine and assert the session resumes
563 // cleanly with the summary message in place.
564 //
565 // The cycle_manager path replaces high-level compaction in v0.6.6+; this
566 // test should target whichever path is enabled by the test config.
567 unreachable!("ignored");
568 }
569
570 #[tokio::test]
571 #[ignore = "blocked on #402: engine takes concrete DeepSeekClient; needs Arc<dyn LlmClient> refactor"]
572 async fn engine_full_sub_agent_spawn_round_trip() {
573 // Once the refactor lands:
574 // 1. Inject MockLlmClient as the parent client AND wire the subagent
575 // runtime to receive its own MockLlmClient.
576 // 2. Parent emits agent_spawn tool_call; child runs through the v0.6.7
577 // mailbox and replies with text.
578 // 3. Assert the final assistant text bubbles back to the parent session.
579 unreachable!("ignored");
580 }
581
582 #[tokio::test]
583 #[ignore = "blocked on #402: engine takes concrete DeepSeekClient; needs Arc<dyn LlmClient> refactor"]
584 async fn engine_full_parallel_tool_execution() {
585 // Once the refactor lands:
586 // 1. Mock turn 1 returns two tool_calls in a single round.
587 // 2. Engine executes them in parallel via FuturesUnordered.
588 // 3. Assert ordered ToolResult messages are appended to the next request.
589 unreachable!("ignored");
590 }
591
592 #[tokio::test]
593 #[ignore = "blocked on #402: engine takes concrete DeepSeekClient; needs Arc<dyn LlmClient> refactor"]
594 async fn engine_capacity_controller_forces_compaction_at_threshold() {
595 // Once the refactor lands:
596 // 1. Inject a long history near the V4 soft cap.
597 // 2. Assert the capacity controller emits a forced-compaction guardrail
598 // BEFORE dispatching the LLM call.
599 // 3. Verify the mock's call_count() reflects the observed sequence.
600 unreachable!("ignored");
601 }
602
602 lines RUST