返回 CodeWhale
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." Full engine integration coverage remains blocked on
36 //! that seam; this file keeps the blocker documented instead of carrying
37 //! ignored placeholder tests.
38 //!
39 //! Once `Arc<dyn LlmClient>` lands, add engine-level tests that reuse this 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/model_catalog.rs"]
46 mod model_catalog;
47
48 #[path = "../src/models.rs"]
49 #[allow(dead_code)]
50 mod models;
51
52 // Mirror the real `llm_client` module hierarchy so that `mock.rs`'s
53 // `super::{LlmClient, StreamEventBox}` paths resolve. We re-declare a local
54 // `LlmClient` trait + `StreamEventBox` alias that match the production shape
55 // 1:1 (the public surface that ships in the binary). The mock implements
56 // this local trait, which is structurally identical to the production trait.
57 //
58 // The helper file lives under `tests/support/` so cargo does not try to
59 // compile it as its own test binary.
60 #[path = "support/llm_client.rs"]
61 mod llm_client;
62
63 use crate::llm_client::LlmClient;
64 use crate::llm_client::mock::{MockLlmClient, canned};
65 use crate::models::{ContentBlock, Delta, Message, MessageRequest, StreamEvent, Usage};
66
67 // === Helpers ===============================================================
68
69 fn user_message(text: &str) -> Message {
70 Message {
71 role: "user".to_string(),
72 content: vec![ContentBlock::Text {
73 text: text.to_string(),
74 cache_control: None,
75 }],
76 }
77 }
78
79 fn assistant_thinking(thinking: &str, text: &str) -> Message {
80 Message {
81 role: "assistant".to_string(),
82 content: vec![
83 ContentBlock::Thinking {
84 thinking: thinking.to_string(),
85 signature: None,
86 },
87 ContentBlock::Text {
88 text: text.to_string(),
89 cache_control: None,
90 },
91 ],
92 }
93 }
94
95 fn assistant_tool_call(id: &str, name: &str, input: serde_json::Value) -> Message {
96 Message {
97 role: "assistant".to_string(),
98 content: vec![ContentBlock::ToolUse {
99 id: id.to_string(),
100 name: name.to_string(),
101 input,
102 caller: None,
103 }],
104 }
105 }
106
107 fn tool_result_message(tool_use_id: &str, content: &str) -> Message {
108 Message {
109 role: "user".to_string(),
110 content: vec![ContentBlock::ToolResult {
111 tool_use_id: tool_use_id.to_string(),
112 content: content.to_string(),
113 is_error: None,
114 content_blocks: None,
115 }],
116 }
117 }
118
119 fn make_request(messages: Vec<Message>) -> MessageRequest {
120 MessageRequest {
121 model: "deepseek-v4-pro".to_string(),
122 messages,
123 max_tokens: 4096,
124 system: None,
125 tools: None,
126 tool_choice: None,
127 metadata: None,
128 thinking: None,
129 reasoning_effort: Some("high".to_string()),
130 stream: Some(true),
131 temperature: None,
132 top_p: None,
133 }
134 }
135
136 async fn drain_stream_text(
137 mock: &MockLlmClient,
138 request: MessageRequest,
139 ) -> (String, Option<String>) {
140 let mut stream = mock
141 .create_message_stream(request)
142 .await
143 .expect("stream open");
144 let mut text = String::new();
145 let mut stop_reason: Option<String> = None;
146 while let Some(ev) = stream.next().await {
147 match ev.expect("event") {
148 StreamEvent::ContentBlockDelta {
149 delta: Delta::TextDelta { text: t },
150 ..
151 } => text.push_str(&t),
152 StreamEvent::MessageDelta { delta, .. } => {
153 stop_reason = delta.stop_reason;
154 }
155 StreamEvent::MessageStop => break,
156 _ => {}
157 }
158 }
159 (text, stop_reason)
160 }
161
162 // === 1. Full turn loop with streaming =======================================
163
164 #[tokio::test]
165 async fn full_turn_loop_streams_text_chunks() {
166 // Two text deltas + finish reason — exercises the canonical streaming
167 // turn-loop path the engine drives.
168 let turn = vec![
169 canned::message_start("msg_1"),
170 canned::text_block_start(0),
171 canned::text_delta(0, "Hello, "),
172 canned::text_delta(0, "world!"),
173 canned::block_stop(0),
174 canned::message_delta("end_turn", Some(Usage::default())),
175 canned::message_stop(),
176 ];
177 let mock = MockLlmClient::new(vec![turn]);
178
179 let request = make_request(vec![user_message("greet me")]);
180 let (text, stop) = drain_stream_text(&mock, request).await;
181
182 assert_eq!(text, "Hello, world!");
183 assert_eq!(stop.as_deref(), Some("end_turn"));
184 assert_eq!(mock.call_count(), 1);
185 assert_eq!(mock.captured_requests().len(), 1);
186 }
187
188 // === 2. Reasoning replay (V4 thinking-mode HTTP-400 regression) =============
189
190 #[tokio::test]
191 async fn reasoning_replay_required_on_subsequent_turn() {
192 // Turn 1: assistant emits thinking + tool_call. Turn 2: text reply.
193 let turn1 = vec![
194 canned::message_start("r1"),
195 canned::thinking_delta(0, "I should call list_dir."),
196 canned::tool_use_block_start(1, "call_a", "list_dir"),
197 canned::tool_input_delta(1, r#"{"path":"/tmp"}"#),
198 canned::block_stop(1),
199 canned::message_delta("tool_use", None),
200 canned::message_stop(),
201 ];
202 let turn2 = vec![
203 canned::message_start("r2"),
204 canned::text_block_start(0),
205 canned::text_delta(0, "I see /tmp."),
206 canned::block_stop(0),
207 canned::message_delta("end_turn", None),
208 canned::message_stop(),
209 ];
210 let mock = MockLlmClient::new(vec![turn1, turn2]);
211
212 // === Round 1: user prompt -> assistant tool_call ===
213 let req1 = make_request(vec![user_message("list /tmp")]);
214 let _ = mock.create_message_stream(req1).await.unwrap().next().await;
215 // (we don't drain — capture is what matters here)
216
217 // === Round 2: runtime composes the next request including the prior
218 // assistant turn's reasoning_content. The mock can verify that any
219 // ContentBlock::Thinking the runtime preserves is present in the next
220 // outgoing request — the very payload shape that broke v0.4.9-v0.5.1.
221 let next_messages = vec![
222 user_message("list /tmp"),
223 assistant_thinking("I should call list_dir.", ""),
224 assistant_tool_call("call_a", "list_dir", serde_json::json!({ "path": "/tmp" })),
225 tool_result_message("call_a", "/tmp/file1\n/tmp/file2"),
226 ];
227 let req2 = make_request(next_messages);
228 let _ = mock.create_message_stream(req2).await.unwrap();
229
230 // The mock captured both requests. Assert the SECOND request preserves
231 // the prior assistant message's Thinking block — i.e. the runtime did
232 // not strip reasoning_content before re-sending. (V4 thinking-mode tool
233 // turns reject HTTP 400 if reasoning_content is missing.)
234 let captured = mock.captured_requests();
235 assert_eq!(captured.len(), 2);
236
237 let req2 = &captured[1];
238 let assistant_with_thinking = req2
239 .messages
240 .iter()
241 .find(|m| {
242 m.role == "assistant"
243 && m.content
244 .iter()
245 .any(|b| matches!(b, ContentBlock::Thinking { .. }))
246 })
247 .expect("turn 2 request must replay assistant Thinking content");
248
249 let thinking_text = assistant_with_thinking
250 .content
251 .iter()
252 .find_map(|b| match b {
253 ContentBlock::Thinking { thinking, .. } => Some(thinking.clone()),
254 _ => None,
255 })
256 .expect("Thinking block present");
257 assert_eq!(
258 thinking_text, "I should call list_dir.",
259 "reasoning_content must be replayed verbatim across tool-call rounds"
260 );
261 }
262
263 // === 3. Tool-call round-trip ================================================
264
265 #[tokio::test]
266 async fn tool_call_round_trip_streams_args_then_continues() {
267 // Turn 1 emits a tool_use block with chunked input JSON.
268 let turn1 = vec![
269 canned::message_start("rt1"),
270 canned::tool_use_block_start(0, "call_x", "read_file"),
271 canned::tool_input_delta(0, r#"{"path":"#),
272 canned::tool_input_delta(0, r#""README.md"}"#),
273 canned::block_stop(0),
274 canned::message_delta("tool_use", None),
275 canned::message_stop(),
276 ];
277 let turn2 = vec![
278 canned::message_start("rt2"),
279 canned::text_block_start(0),
280 canned::text_delta(0, "README starts with: # deepseek-tui"),
281 canned::block_stop(0),
282 canned::message_delta("end_turn", None),
283 canned::message_stop(),
284 ];
285 let mock = MockLlmClient::new(vec![turn1, turn2]);
286
287 // Round 1
288 let mut s1 = mock
289 .create_message_stream(make_request(vec![user_message("read README.md")]))
290 .await
291 .unwrap();
292
293 let mut tool_use_seen = false;
294 let mut json_seen = String::new();
295 while let Some(ev) = s1.next().await {
296 match ev.unwrap() {
297 StreamEvent::ContentBlockStart { content_block, .. } => {
298 use crate::models::ContentBlockStart;
299 if let ContentBlockStart::ToolUse { name, .. } = content_block {
300 assert_eq!(name, "read_file");
301 tool_use_seen = true;
302 }
303 }
304 StreamEvent::ContentBlockDelta {
305 delta: Delta::InputJsonDelta { partial_json },
306 ..
307 } => json_seen.push_str(&partial_json),
308 StreamEvent::MessageStop => break,
309 _ => {}
310 }
311 }
312 assert!(tool_use_seen);
313 let parsed: serde_json::Value =
314 serde_json::from_str(&json_seen).expect("valid JSON after concat");
315 assert_eq!(parsed["path"], "README.md");
316
317 // Round 2 — runtime sends back a tool_result and the mock replies with
318 // the final assistant text turn.
319 let req2 = make_request(vec![
320 user_message("read README.md"),
321 assistant_tool_call(
322 "call_x",
323 "read_file",
324 serde_json::json!({ "path": "README.md" }),
325 ),
326 tool_result_message("call_x", "# deepseek-tui\n..."),
327 ]);
328 let (text, stop) = drain_stream_text(&mock, req2).await;
329 assert!(text.contains("# deepseek-tui"));
330 assert_eq!(stop.as_deref(), Some("end_turn"));
331 }
332
333 // === 4. Multiple tool calls in one round (parallel ordering) ================
334
335 #[tokio::test]
336 async fn parallel_tool_calls_preserve_ordering_in_turn_payload() {
337 // Assistant returns two tool_calls in a single turn (indices 0 and 1).
338 // The runtime is free to execute them in parallel; this test asserts that
339 // the canonical event ordering survives a single-turn replay.
340 let turn = vec![
341 canned::message_start("p1"),
342 canned::tool_use_block_start(0, "call_one", "list_dir"),
343 canned::tool_input_delta(0, r#"{"path":"a"}"#),
344 canned::block_stop(0),
345 canned::tool_use_block_start(1, "call_two", "list_dir"),
346 canned::tool_input_delta(1, r#"{"path":"b"}"#),
347 canned::block_stop(1),
348 canned::message_delta("tool_use", None),
349 canned::message_stop(),
350 ];
351 let mock = MockLlmClient::new(vec![turn]);
352
353 let mut stream = mock
354 .create_message_stream(make_request(vec![user_message("list both")]))
355 .await
356 .unwrap();
357
358 let mut starts: Vec<(u32, String)> = Vec::new();
359 while let Some(ev) = stream.next().await {
360 if let StreamEvent::ContentBlockStart {
361 index,
362 content_block,
363 } = ev.unwrap()
364 {
365 use crate::models::ContentBlockStart;
366 if let ContentBlockStart::ToolUse { id, .. } = content_block {
367 starts.push((index, id));
368 }
369 }
370 }
371
372 assert_eq!(starts.len(), 2);
373 assert_eq!(starts[0], (0, "call_one".to_string()));
374 assert_eq!(starts[1], (1, "call_two".to_string()));
375 }
376
377 // === 5. Compaction-style non-streaming call =================================
378
379 #[tokio::test]
380 async fn compaction_non_streaming_returns_queued_message_response() {
381 use crate::models::MessageResponse;
382
383 let mock = MockLlmClient::new(vec![]);
384 mock.push_message_response(MessageResponse {
385 id: "compact_msg".to_string(),
386 r#type: "message".to_string(),
387 role: "assistant".to_string(),
388 content: vec![ContentBlock::Text {
389 text: "## Summary\n- Step 1\n- Step 2".to_string(),
390 cache_control: None,
391 }],
392 model: "deepseek-v4-pro".to_string(),
393 stop_reason: Some("end_turn".to_string()),
394 stop_sequence: None,
395 container: None,
396 usage: Usage::default(),
397 });
398
399 // The runtime's compaction path uses create_message (not stream).
400 let req = MessageRequest {
401 stream: Some(false),
402 ..make_request(vec![user_message("summarize")])
403 };
404 let resp = mock.create_message(req).await.unwrap();
405
406 let text = match &resp.content[0] {
407 ContentBlock::Text { text, .. } => text.clone(),
408 _ => panic!("expected text content"),
409 };
410 assert!(text.contains("Summary"));
411 assert_eq!(resp.id, "compact_msg");
412 assert_eq!(mock.call_count(), 1);
413 }
414
415 // === 6. Sub-agent style turn ================================================
416 //
417 // The next turn after an `agent` summary must re-verify the claimed
418 // side effect before reporting success.
419
420 #[tokio::test]
421 async fn v4_parent_reverifies_subagent_file_self_report_before_claiming_success() {
422 let tmp = tempfile::tempdir().expect("tempdir");
423 let missing = tmp.path().join("child-claimed-write.txt");
424 assert!(!missing.exists(), "fixture path must start missing");
425 let missing_path = missing.display().to_string();
426
427 let parent = MockLlmClient::new(vec![vec![
428 canned::message_start("parent_verify"),
429 canned::thinking_delta(0, "Verify the child's file-write self-report first."),
430 canned::tool_use_block_start(1, "verify_file", "read_file"),
431 canned::tool_input_delta(1, &serde_json::json!({ "path": &missing_path }).to_string()),
432 canned::block_stop(1),
433 canned::message_delta("tool_use", None),
434 canned::message_stop(),
435 ]])
436 .with_model("deepseek-v4-pro");
437 let tool_summary = format!(
438 "[sub-agent result summarized for parent context]\n\
439 Child results are self-reports; verify side effects with tools like read_file or list_dir before claiming success.\n\
440 - agent_filecheck (implementer) status=Completed\n result: Wrote {missing_path} successfully."
441 );
442
443 let mut stream = parent
444 .create_message_stream(make_request(vec![
445 user_message("Use a child to create the file, then report back."),
446 assistant_tool_call(
447 "agent_call",
448 "agent",
449 serde_json::json!({
450 "prompt": "Create the requested file and report the result.",
451 "role": "implementer"
452 }),
453 ),
454 tool_result_message("agent_call", &tool_summary),
455 ]))
456 .await
457 .unwrap();
458
459 let mut text_before_verification = String::new();
460 let mut tool_name = None;
461 let mut tool_input = String::new();
462 while let Some(ev) = stream.next().await {
463 match ev.unwrap() {
464 StreamEvent::ContentBlockStart { content_block, .. } => {
465 use crate::models::ContentBlockStart;
466 if let ContentBlockStart::ToolUse { name, .. } = content_block {
467 tool_name = Some(name);
468 }
469 }
470 StreamEvent::ContentBlockDelta { delta, .. } => match delta {
471 Delta::InputJsonDelta { partial_json } => tool_input.push_str(&partial_json),
472 Delta::TextDelta { text } => text_before_verification.push_str(&text),
473 _ => {}
474 },
475 StreamEvent::MessageStop => break,
476 _ => {}
477 }
478 }
479
480 assert_eq!(text_before_verification, "");
481 assert_eq!(tool_name.as_deref(), Some("read_file"));
482 let parsed: serde_json::Value = serde_json::from_str(&tool_input).expect("tool input JSON");
483 assert_eq!(parsed["path"], missing_path);
484 }
485
486 // === 7. Request capture observation =========================================
487 //
488 // The mock surfaces request captures BEFORE the response stream is opened, so
489 // trait-level tests can verify that captured requests are observable per-call
490 // rather than buffered across calls.
491
492 #[tokio::test]
493 async fn capacity_gate_can_observe_request_before_response_streams() {
494 let turn = vec![canned::simple_text_turn("ok")];
495 let mock = MockLlmClient::new(turn);
496
497 // Build a "near-limit" request — many user messages.
498 let mut messages = Vec::new();
499 for i in 0..200 {
500 messages.push(user_message(&format!("m{i}")));
501 }
502 let req = make_request(messages);
503
504 // BEFORE the runtime drains the stream, the mock has already captured
505 // the request. The capacity controller can inspect this and short-circuit
506 // the dispatch if the estimated token cost exceeds the soft cap.
507 let stream_future = mock.create_message_stream(req);
508 let mut stream = stream_future.await.unwrap();
509
510 assert_eq!(mock.captured_requests().len(), 1);
511 let captured = mock.last_request().unwrap();
512 assert_eq!(captured.messages.len(), 200);
513 // Verify the capacity gate could compute a "should defer" decision based
514 // on raw message count + payload size of the captured request.
515 let total_chars: usize = captured
516 .messages
517 .iter()
518 .flat_map(|m| m.content.iter())
519 .map(|b| match b {
520 ContentBlock::Text { text, .. } => text.len(),
521 _ => 0,
522 })
523 .sum();
524 assert!(
525 total_chars > 100,
526 "synthetic over-cap request should have non-trivial size"
527 );
528
529 // Drain to keep the mock state consistent.
530 while stream.next().await.is_some() {}
531 }
532
533 // === 8. Compaction defaults (#402 P0) ======================================
534
535 #[test]
536 fn compaction_config_defaults_are_enabled_for_session_survivability() {
537 // The production CompactionConfig is gated behind a `#[path = ...]` module
538 // that isn't wired here, but we can test the principle: the
539 // `should_compact` function and `CompactionConfig` live in the same crate.
540 // Re-import from the production module to verify the default.
541 //
542 // We test via the mock pathway: the non-streaming compaction call (test 5
543 // above) already exercises `create_message` with `stream: Some(false)`,
544 // which is the code path `compact_messages` uses. Combined with the
545 // capacity controller's `TargetedContextRefresh`, the enabled-by-default
546 // compaction config means long sessions auto-compact before hitting the
547 // context window limit.
548 //
549 // This test is a smoke check that the defaults compile and are correct.
550 // The production `CompactionConfig::default()` is exercised by
551 // `compaction::tests::should_compact_respects_enabled_flag` etc.
552 let config = crate::models::compaction_threshold_for_model_at_percent("deepseek-v4-pro", 80.0);
553 // Verify the threshold is reasonable (> 0 and < context window).
554 assert!(config > 0, "compaction threshold must be positive");
555 assert!(config < 1_000_000, "compaction threshold must be below 1M");
556 }
557
557 lines RUST