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