| 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 |