| 1 | use super::*; |
| 2 | use crate::core::ops::TurnSpec; |
| 3 | |
| 4 | fn tool(name: &str, deferred: bool) -> Tool { |
| 5 | Tool { |
| 6 | tool_type: None, |
| 7 | name: name.to_string(), |
| 8 | description: format!("{name} description"), |
| 9 | input_schema: json!({"type": "object", "properties": {}}), |
| 10 | allowed_callers: None, |
| 11 | defer_loading: Some(deferred), |
| 12 | input_examples: None, |
| 13 | strict: None, |
| 14 | cache_control: None, |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | #[test] |
| 19 | fn active_catalog_hash_tracks_membership_order_and_schema() { |
| 20 | let base = vec![tool("Bash", false), tool("File", false)]; |
| 21 | let baseline = active_tool_catalog_sha256(&base); |
| 22 | |
| 23 | assert_eq!(baseline, active_tool_catalog_sha256(&base.clone())); |
| 24 | |
| 25 | let mut reordered = base.clone(); |
| 26 | reordered.swap(0, 1); |
| 27 | assert_ne!(baseline, active_tool_catalog_sha256(&reordered)); |
| 28 | |
| 29 | let mut fewer = base.clone(); |
| 30 | fewer.pop(); |
| 31 | assert_ne!(baseline, active_tool_catalog_sha256(&fewer)); |
| 32 | |
| 33 | let mut retyped = base.clone(); |
| 34 | retyped[0].input_schema = json!({"type": "object", "required": ["cmd"]}); |
| 35 | assert_ne!(baseline, active_tool_catalog_sha256(&retyped)); |
| 36 | } |
| 37 | |
| 38 | #[test] |
| 39 | fn preview_and_production_share_one_input_estimate_and_send_decision() { |
| 40 | let messages = vec![Message { |
| 41 | role: Role::User, |
| 42 | content: vec![ContentBlock::Text { |
| 43 | text: "near the context ceiling".to_string(), |
| 44 | cache_control: None, |
| 45 | }], |
| 46 | }]; |
| 47 | let system = SystemPrompt::Text("stable system".to_string()); |
| 48 | |
| 49 | // Production sends stored history and nothing else, and preview describes |
| 50 | // that same list, so a single estimate drives the manifest number and the |
| 51 | // overflow/no-exact-outbound decision. There is no second synthetic list |
| 52 | // to charge a separate framing overhead for. |
| 53 | let estimate = crate::compaction::estimate_input_tokens_conservative(&messages, Some(&system)); |
| 54 | |
| 55 | let ceiling = estimate - 1; |
| 56 | assert_eq!( |
| 57 | crate::request_manifest::production_input_headroom(Some(ceiling), estimate), |
| 58 | Some(-1) |
| 59 | ); |
| 60 | assert!(crate::request_manifest::production_input_budget_exceeded( |
| 61 | Some(ceiling), |
| 62 | estimate |
| 63 | )); |
| 64 | assert!(!crate::request_manifest::production_input_budget_exceeded( |
| 65 | Some(estimate), |
| 66 | estimate |
| 67 | )); |
| 68 | } |
| 69 | |
| 70 | #[test] |
| 71 | fn standard_and_full_are_reported_collapsed_from_the_real_shaper() { |
| 72 | let catalog = vec![tool("Bash", false), tool("agent", false), tool("Web", true)]; |
| 73 | let always_load = std::collections::HashSet::new(); |
| 74 | assert!( |
| 75 | standard_and_full_collapse(&catalog, &always_load), |
| 76 | "Standard and Full apply no narrowing today, so they must report collapsed" |
| 77 | ); |
| 78 | } |
| 79 | |
| 80 | #[tokio::test] |
| 81 | async fn pending_shell_completion_makes_the_body_unavailable_without_draining_it() { |
| 82 | let config = deepseek_config(); |
| 83 | let identity = deepseek_identity(); |
| 84 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 85 | engine.config.features.disable(Feature::Mcp); |
| 86 | let owner_session_id = engine.session.id.clone(); |
| 87 | |
| 88 | { |
| 89 | let mut manager = engine.shell_manager.lock().expect("shell manager"); |
| 90 | let command = if cfg!(windows) { |
| 91 | "Start-Sleep -Seconds 30" |
| 92 | } else { |
| 93 | "sleep 30" |
| 94 | }; |
| 95 | manager |
| 96 | .execute_with_options_env_for_session( |
| 97 | command, |
| 98 | None, |
| 99 | 30_000, |
| 100 | true, |
| 101 | None, |
| 102 | false, |
| 103 | None, |
| 104 | std::collections::HashMap::new(), |
| 105 | &owner_session_id, |
| 106 | ) |
| 107 | .expect("background shell starts"); |
| 108 | assert!(manager.may_have_undelivered_completion_for_session(&owner_session_id)); |
| 109 | } |
| 110 | |
| 111 | let planned = plan(&config, &identity, false, "inspect the next request").await; |
| 112 | let manifest = engine |
| 113 | .build_request_manifest(inputs(false, Some(planned), "inspect the next request")) |
| 114 | .await; |
| 115 | let unavailable = match &manifest.body { |
| 116 | Availability::Unavailable(unavailable) => unavailable, |
| 117 | Availability::Exact(_) => panic!("pending shell completion must fail closed"), |
| 118 | }; |
| 119 | assert_eq!( |
| 120 | unavailable.reason, |
| 121 | UnavailableReason::RuntimeTransformsBeforeSend |
| 122 | ); |
| 123 | assert!( |
| 124 | unavailable |
| 125 | .detail |
| 126 | .as_deref() |
| 127 | .is_some_and(|detail| detail.contains("background shell completion")), |
| 128 | "{unavailable:?}" |
| 129 | ); |
| 130 | |
| 131 | let mut manager = engine.shell_manager.lock().expect("shell manager"); |
| 132 | assert!( |
| 133 | manager.may_have_undelivered_completion_for_session(&owner_session_id), |
| 134 | "preview must not drain or report the completion" |
| 135 | ); |
| 136 | let _ = manager.kill_running(); |
| 137 | let _ = manager.drain_finished_jobs_with_evidence(); |
| 138 | } |
| 139 | |
| 140 | #[tokio::test] |
| 141 | async fn running_direct_child_fails_closed_without_consuming_or_mutating_state() { |
| 142 | let config = deepseek_config(); |
| 143 | let identity = deepseek_identity(); |
| 144 | let (mut engine, _handle, tmp) = preview_engine(&config); |
| 145 | engine.config.features.disable(Feature::Mcp); |
| 146 | let active_session_id = engine.session.id.clone(); |
| 147 | let mut before = { |
| 148 | let mut manager = engine.subagent_manager.write().await; |
| 149 | let agent_id = manager.insert_test_running_direct_child("preview_pending", tmp.path()); |
| 150 | manager.assign_test_session_owner(&agent_id, &active_session_id); |
| 151 | serde_json::to_value(manager.list()).expect("manager snapshot") |
| 152 | }; |
| 153 | if let Some(rows) = before.as_array_mut() { |
| 154 | for row in rows { |
| 155 | row.as_object_mut() |
| 156 | .expect("agent object") |
| 157 | .remove("duration_ms"); |
| 158 | } |
| 159 | } |
| 160 | let delivered_before = engine.delivered_subagent_completion_ids.clone(); |
| 161 | |
| 162 | let planned = plan(&config, &identity, false, "inspect while child runs").await; |
| 163 | let manifest = engine |
| 164 | .build_request_manifest(inputs(false, Some(planned), "inspect while child runs")) |
| 165 | .await; |
| 166 | let unavailable = match &manifest.body { |
| 167 | Availability::Unavailable(unavailable) => unavailable, |
| 168 | Availability::Exact(_) => panic!("running child must fail closed"), |
| 169 | }; |
| 170 | assert_eq!( |
| 171 | unavailable.reason, |
| 172 | UnavailableReason::RuntimeTransformsBeforeSend |
| 173 | ); |
| 174 | assert!( |
| 175 | unavailable |
| 176 | .detail |
| 177 | .as_deref() |
| 178 | .is_some_and(|detail| detail.contains("running or undelivered sub-agent")) |
| 179 | ); |
| 180 | |
| 181 | let mut after = { |
| 182 | let manager = engine.subagent_manager.read().await; |
| 183 | assert!(manager.may_transform_next_parent_request_for_session( |
| 184 | &active_session_id, |
| 185 | &engine.delivered_subagent_completion_ids, |
| 186 | )); |
| 187 | serde_json::to_value(manager.list()).expect("manager snapshot") |
| 188 | }; |
| 189 | if let Some(rows) = after.as_array_mut() { |
| 190 | for row in rows { |
| 191 | row.as_object_mut() |
| 192 | .expect("agent object") |
| 193 | .remove("duration_ms"); |
| 194 | } |
| 195 | } |
| 196 | assert_eq!(before, after, "preview must not mutate child state"); |
| 197 | assert_eq!( |
| 198 | delivered_before, engine.delivered_subagent_completion_ids, |
| 199 | "preview must not claim child delivery" |
| 200 | ); |
| 201 | } |
| 202 | |
| 203 | #[tokio::test] |
| 204 | async fn terminal_undelivered_child_fails_closed_without_claiming_delivery() { |
| 205 | let config = deepseek_config(); |
| 206 | let identity = deepseek_identity(); |
| 207 | let (mut engine, _handle, tmp) = preview_engine(&config); |
| 208 | engine.config.features.disable(Feature::Mcp); |
| 209 | let active_session_id = engine.session.id.clone(); |
| 210 | let agent_id = { |
| 211 | let mut manager = engine.subagent_manager.write().await; |
| 212 | let agent_id = manager.insert_test_terminal_direct_child("preview_terminal", tmp.path()); |
| 213 | manager.assign_test_session_owner(&agent_id, &active_session_id); |
| 214 | agent_id |
| 215 | }; |
| 216 | |
| 217 | let planned = plan(&config, &identity, false, "inspect settled child").await; |
| 218 | let manifest = engine |
| 219 | .build_request_manifest(inputs(false, Some(planned), "inspect settled child")) |
| 220 | .await; |
| 221 | assert!(matches!(manifest.body, Availability::Unavailable(_))); |
| 222 | assert!( |
| 223 | !engine.delivered_subagent_completion_ids.contains(&agent_id), |
| 224 | "preview must not claim terminal delivery" |
| 225 | ); |
| 226 | let manager = engine.subagent_manager.read().await; |
| 227 | assert!(manager.may_transform_next_parent_request_for_session( |
| 228 | &active_session_id, |
| 229 | &engine.delivered_subagent_completion_ids, |
| 230 | )); |
| 231 | assert!(matches!( |
| 232 | manager |
| 233 | .get_result(&agent_id) |
| 234 | .expect("terminal child") |
| 235 | .status, |
| 236 | crate::tools::subagent::SubAgentStatus::Completed |
| 237 | )); |
| 238 | } |
| 239 | |
| 240 | #[test] |
| 241 | fn turn_metadata_uses_planned_cross_route_limits_not_installed_limits() { |
| 242 | let config = deepseek_config(); |
| 243 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 244 | // Pressure advice is only surfaced when the user opts out of automatic |
| 245 | // maintenance. The route-budget assertion still applies in that mode. |
| 246 | engine.config.compaction.enabled = false; |
| 247 | engine.api_provider = ApiProvider::Deepseek; |
| 248 | let installed_limits = codewhale_config::route::RouteLimits { |
| 249 | context_tokens: Some(4_096), |
| 250 | input_tokens: None, |
| 251 | output_tokens: Some(512), |
| 252 | }; |
| 253 | engine.active_route_limits = Some(installed_limits); |
| 254 | // Large enough to be critical for the installed 4K route, but safely |
| 255 | // below the warning threshold for the planned 123K route. |
| 256 | engine.session.messages.push(Message { |
| 257 | role: Role::User, |
| 258 | content: vec![ContentBlock::Text { |
| 259 | text: "x".repeat(20_000), |
| 260 | cache_control: None, |
| 261 | }], |
| 262 | }); |
| 263 | let prompt_context = NextTurnPromptContext::for_planned_turn( |
| 264 | ApiProvider::Openrouter, |
| 265 | "qwen/qwen3.6-flash".to_string(), |
| 266 | Some(codewhale_config::route::RouteLimits { |
| 267 | context_tokens: Some(123_456), |
| 268 | input_tokens: None, |
| 269 | output_tokens: Some(4_096), |
| 270 | }), |
| 271 | AppMode::Agent, |
| 272 | None, |
| 273 | GoalStatus::Active, |
| 274 | None, |
| 275 | false, |
| 276 | None, |
| 277 | ); |
| 278 | let system_prompt = engine.compose_stable_system_prompt(&prompt_context); |
| 279 | assert_eq!( |
| 280 | engine.context_pressure_line( |
| 281 | "cross-route budget", |
| 282 | &prompt_context, |
| 283 | system_prompt.as_ref() |
| 284 | ), |
| 285 | None, |
| 286 | "the planned 123K route must not inherit the installed route's pressure" |
| 287 | ); |
| 288 | let installed_context = NextTurnPromptContext::for_planned_turn( |
| 289 | ApiProvider::Deepseek, |
| 290 | "deepseek-v4-flash".to_string(), |
| 291 | Some(installed_limits), |
| 292 | AppMode::Agent, |
| 293 | None, |
| 294 | GoalStatus::Active, |
| 295 | None, |
| 296 | false, |
| 297 | None, |
| 298 | ); |
| 299 | let pressure = engine |
| 300 | .context_pressure_line("cross-route budget", &installed_context, None) |
| 301 | .unwrap(); |
| 302 | assert!(pressure.contains("Context pressure: critical")); |
| 303 | assert!(pressure.contains("Estimated input:")); |
| 304 | assert!(pressure.contains("Automatic compaction is explicitly disabled")); |
| 305 | let message = engine.user_text_message_from_snapshot( |
| 306 | "cross-route budget".to_string(), |
| 307 | &prompt_context.model, |
| 308 | true, |
| 309 | None, |
| 310 | false, |
| 311 | UserInputProvenance::ExternalUser, |
| 312 | TurnMetadataSnapshot { |
| 313 | prompt_context: &prompt_context, |
| 314 | system_prompt: system_prompt.as_ref(), |
| 315 | approval_mode: ApprovalMode::Suggest, |
| 316 | working_set: &engine.session.working_set, |
| 317 | policy_narrowing: None, |
| 318 | }, |
| 319 | ); |
| 320 | let metadata = message |
| 321 | .content |
| 322 | .iter() |
| 323 | .filter_map(|block| match block { |
| 324 | ContentBlock::Text { text, .. } => Some(text.as_str()), |
| 325 | _ => None, |
| 326 | }) |
| 327 | .next_back() |
| 328 | .expect("turn metadata text"); |
| 329 | assert!( |
| 330 | !metadata.contains("Context pressure:"), |
| 331 | "planned route metadata must remain below warning: {metadata}" |
| 332 | ); |
| 333 | assert!(!metadata.contains("123456 tokens"), "{metadata}"); |
| 334 | assert!(!metadata.contains("4096 tokens"), "{metadata}"); |
| 335 | } |
| 336 | |
| 337 | /// #perf-r5: the pressure-line helper must estimate the history IN PLACE and |
| 338 | /// add the composer text arithmetically. Guards two things at once: |
| 339 | /// |
| 340 | /// 1. Equivalence — the arithmetic form must equal the naive |
| 341 | /// "clone + push + estimate" reference for non-trivial inputs (Unicode |
| 342 | /// multi-byte content included, since Text blocks count *chars* for the |
| 343 | /// conservative estimator but the delta path counts... the same rule as |
| 344 | /// `estimate_tokens_for_message`: bytes/4). |
| 345 | /// 2. The contract that empty/no-op composer text costs nothing extra. |
| 346 | #[test] |
| 347 | fn context_pressure_delta_matches_clone_and_push_reference() { |
| 348 | let config = deepseek_config(); |
| 349 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 350 | engine.api_provider = ApiProvider::Deepseek; |
| 351 | let installed_limits = codewhale_config::route::RouteLimits { |
| 352 | context_tokens: Some(64_000), |
| 353 | input_tokens: None, |
| 354 | output_tokens: Some(512), |
| 355 | }; |
| 356 | engine.active_route_limits = Some(installed_limits); |
| 357 | // Multi-byte content on purpose: chars().count() != len() here, so an |
| 358 | // arity mistake between the byte rule (estimator) would surface. |
| 359 | engine.session.messages.push(Message { |
| 360 | role: Role::User, |
| 361 | content: vec![ContentBlock::Text { |
| 362 | text: "héllo wörld — ünïcode ✓ ".repeat(500), |
| 363 | cache_control: None, |
| 364 | }], |
| 365 | }); |
| 366 | engine.session.messages.push(Message { |
| 367 | role: Role::Assistant, |
| 368 | content: vec![ContentBlock::Thinking { |
| 369 | thinking: "step".repeat(100), |
| 370 | signature: None, |
| 371 | state: None, |
| 372 | }], |
| 373 | }); |
| 374 | // Replayed-reasoning case (#perf-r5 fresh-eyes fix): an assistant message |
| 375 | // carrying BOTH thinking and a tool call keeps its reasoning content in |
| 376 | // every subsequent request — the estimator counts those bytes, and this |
| 377 | // was the exact arm the delta helper originally missed. Both parity |
| 378 | // variants of the thinking byte-count are exercised below. |
| 379 | engine.session.messages.push(Message { |
| 380 | role: Role::Assistant, |
| 381 | content: vec![ |
| 382 | ContentBlock::Thinking { |
| 383 | thinking: "replayed".repeat(300), // 8 bytes per unit -> even count |
| 384 | signature: None, |
| 385 | state: None, |
| 386 | }, |
| 387 | ContentBlock::ToolUse { |
| 388 | id: "call_1".to_string(), |
| 389 | name: "bash".to_string(), |
| 390 | input: json!({"command": "echo hello"}), |
| 391 | caller: None, |
| 392 | thought_signature: None, |
| 393 | }, |
| 394 | ], |
| 395 | }); |
| 396 | engine.session.messages.push(Message { |
| 397 | role: Role::Assistant, |
| 398 | content: vec![ |
| 399 | ContentBlock::Thinking { |
| 400 | thinking: "odd replay".to_string(), // 11 bytes / 4 = 2 (even)... use odd total |
| 401 | signature: None, |
| 402 | state: None, |
| 403 | }, |
| 404 | ContentBlock::ToolUse { |
| 405 | id: "call_2".to_string(), |
| 406 | name: "read".to_string(), |
| 407 | input: json!({"path": "x"}), // 13-byte JSON -> 3 |
| 408 | caller: None, |
| 409 | thought_signature: None, |
| 410 | }, |
| 411 | ], |
| 412 | }); |
| 413 | let _prompt_context = NextTurnPromptContext::for_planned_turn( |
| 414 | ApiProvider::Deepseek, |
| 415 | "deepseek-v4-flash".to_string(), |
| 416 | Some(installed_limits), |
| 417 | AppMode::Agent, |
| 418 | None, |
| 419 | GoalStatus::Active, |
| 420 | None, |
| 421 | false, |
| 422 | None, |
| 423 | ); |
| 424 | let _ = &_prompt_context; |
| 425 | |
| 426 | // Naive reference implementation: clone the transcript, push a |
| 427 | // hypothetical user message, run the full conservative estimator. |
| 428 | let reference = |engine: &Engine, text: &str| -> usize { |
| 429 | let mut messages: Vec<Message> = |
| 430 | crate::prompt_zones::AppendLog::clone(&engine.session.messages).into(); |
| 431 | if !text.trim().is_empty() { |
| 432 | messages.push(Message { |
| 433 | role: Role::User, |
| 434 | content: vec![ContentBlock::Text { |
| 435 | text: text.to_string(), |
| 436 | cache_control: None, |
| 437 | }], |
| 438 | }); |
| 439 | } |
| 440 | crate::compaction::estimate_input_tokens_conservative(&messages, None) |
| 441 | }; |
| 442 | |
| 443 | for text in [ |
| 444 | "", |
| 445 | " ", |
| 446 | "short", |
| 447 | "a much longer composer draft with punctuation…", |
| 448 | ] { |
| 449 | let via_pressure_line_input = engine.active_input_tokens_with_current_text(text, None); |
| 450 | assert_eq!( |
| 451 | via_pressure_line_input, |
| 452 | reference(&engine, text), |
| 453 | "delta arithmetic diverged from clone+push+estimate for {text:?}" |
| 454 | ); |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | /// #perf-r5 guard: billed input above the threshold must report pressure with |
| 459 | /// a provably-empty history — proving the short-circuit answers from billing |
| 460 | /// alone without consulting message contents. |
| 461 | #[test] |
| 462 | fn billed_pressure_above_threshold_answers_from_billing_alone() { |
| 463 | let config = CompactionConfig { |
| 464 | enabled: true, |
| 465 | token_threshold: 1_000, |
| 466 | ..Default::default() |
| 467 | }; |
| 468 | let pressure = crate::compaction::compaction_pressure_reached_with_billed( |
| 469 | &[], // empty history: only billing can prove pressure |
| 470 | None, |
| 471 | &config, |
| 472 | Some(2_000), |
| 473 | ); |
| 474 | assert!(pressure, "billed 2000 >= threshold 1000 must be pressure"); |
| 475 | } |
| 476 | |
| 477 | /// #perf-r5 guard: under-threshold billing keeps the old max() semantics — |
| 478 | /// an estimate above the trigger still fires even when billing is quiet. |
| 479 | #[test] |
| 480 | fn billed_below_threshold_still_fires_on_estimate() { |
| 481 | let config = CompactionConfig { |
| 482 | enabled: true, |
| 483 | token_threshold: 100, |
| 484 | ..Default::default() |
| 485 | }; |
| 486 | let big = Message { |
| 487 | role: Role::User, |
| 488 | content: vec![ContentBlock::Text { |
| 489 | text: "x".repeat(4 * 200), |
| 490 | cache_control: None, |
| 491 | }], |
| 492 | }; |
| 493 | let pressure = crate::compaction::compaction_pressure_reached_with_billed( |
| 494 | std::slice::from_ref(&big), |
| 495 | None, |
| 496 | &config, |
| 497 | Some(10), // below threshold; must not short-circuit to false either |
| 498 | ); |
| 499 | assert!(pressure, "estimate 200 (+1.0 framing) >= 100 must fire"); |
| 500 | } |
| 501 | |
| 502 | /// #perf-r5 guard: a direct `session.messages` overwrite (the SyncSession |
| 503 | /// restore path) must advance `messages_revision` so the token-estimate |
| 504 | /// cache invalidates instead of serving the pre-sync value. |
| 505 | #[test] |
| 506 | fn sync_restore_bumps_messages_revision_for_estimate_cache() { |
| 507 | use crate::core::engine::token_estimate_cache::TokenEstimateCache; |
| 508 | |
| 509 | let config = deepseek_config(); |
| 510 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 511 | engine.session.add_message(Message { |
| 512 | role: Role::User, |
| 513 | content: vec![ContentBlock::Text { |
| 514 | text: "before restore".to_string(), |
| 515 | cache_control: None, |
| 516 | }], |
| 517 | }); |
| 518 | let revision_before = engine.session.messages_revision; |
| 519 | let mut cache = TokenEstimateCache::new(); |
| 520 | let stale = cache.lookup_or_compute( |
| 521 | revision_before, |
| 522 | engine.session.system_prompt.as_ref(), |
| 523 | &engine.session.messages, |
| 524 | ); |
| 525 | |
| 526 | // Simulate the restore's direct field assignment. |
| 527 | engine.session.messages = Vec::new().into(); |
| 528 | engine.session.bump_messages_revision(); |
| 529 | |
| 530 | assert_ne!( |
| 531 | engine.session.messages_revision, revision_before, |
| 532 | "restore must bump the revision the estimate cache keys on" |
| 533 | ); |
| 534 | let fresh = cache.lookup_or_compute( |
| 535 | engine.session.messages_revision, |
| 536 | engine.session.system_prompt.as_ref(), |
| 537 | &engine.session.messages, |
| 538 | ); |
| 539 | assert_ne!( |
| 540 | fresh, stale, |
| 541 | "cache must recompute after a restore-driven revision bump" |
| 542 | ); |
| 543 | } |
| 544 | |
| 545 | #[tokio::test] |
| 546 | async fn compaction_preview_uses_the_planned_routes_system_prompt() { |
| 547 | let config = deepseek_config(); |
| 548 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 549 | engine.config.features.disable(Feature::Mcp); |
| 550 | |
| 551 | let messages: Vec<Message> = (0..30) |
| 552 | .map(|index| Message { |
| 553 | role: if index % 2 == 0 { |
| 554 | Role::User |
| 555 | } else { |
| 556 | Role::Assistant |
| 557 | }, |
| 558 | content: vec![ContentBlock::Text { |
| 559 | text: "x".repeat(10_000), |
| 560 | cache_control: None, |
| 561 | }], |
| 562 | }) |
| 563 | .collect(); |
| 564 | let installed_prompt = SystemPrompt::Text("installed route".to_string()); |
| 565 | let planned_prompt = SystemPrompt::Text("planned-route-system ".repeat(1_500)); |
| 566 | engine.session.system_prompt = Some(installed_prompt.clone()); |
| 567 | |
| 568 | let installed_pressure = |
| 569 | crate::compaction::estimate_input_tokens_for_pressure(&messages, Some(&installed_prompt)); |
| 570 | let planned_pressure = |
| 571 | crate::compaction::estimate_input_tokens_for_pressure(&messages, Some(&planned_prompt)); |
| 572 | assert!(planned_pressure > installed_pressure); |
| 573 | let compaction = crate::compaction::CompactionConfig { |
| 574 | enabled: true, |
| 575 | token_threshold: installed_pressure + (planned_pressure - installed_pressure) / 2, |
| 576 | ..Default::default() |
| 577 | }; |
| 578 | |
| 579 | let planned_reasons = engine |
| 580 | .preview_runtime_transforms(&messages, Some(&planned_prompt), &compaction) |
| 581 | .await; |
| 582 | assert!( |
| 583 | planned_reasons.contains(&"auto-compaction would rewrite the conversation first"), |
| 584 | "the planned route prompt crosses the compaction threshold: {planned_reasons:?}" |
| 585 | ); |
| 586 | |
| 587 | let installed_reasons = engine |
| 588 | .preview_runtime_transforms(&messages, Some(&installed_prompt), &compaction) |
| 589 | .await; |
| 590 | assert!( |
| 591 | !installed_reasons.contains(&"auto-compaction would rewrite the conversation first"), |
| 592 | "the installed route prompt is the below-threshold control: {installed_reasons:?}" |
| 593 | ); |
| 594 | } |
| 595 | |
| 596 | #[tokio::test] |
| 597 | async fn planned_route_builds_subagent_catalog_without_installed_client() { |
| 598 | let config = deepseek_config(); |
| 599 | let identity = deepseek_identity(); |
| 600 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 601 | engine.config.features.disable(Feature::Mcp); |
| 602 | let _ = engine.config.features.enable(Feature::Subagents); |
| 603 | engine.config.subagents_enabled = true; |
| 604 | engine.codewhale_client = None; |
| 605 | let planned = plan(&config, &identity, false, "planned child route").await; |
| 606 | let route = planned.route.validate().expect("planned route validates"); |
| 607 | let planned_model = route.model.clone(); |
| 608 | let policy = TurnAuthority::from_effective_fields( |
| 609 | AppMode::Agent, |
| 610 | false, |
| 611 | false, |
| 612 | false, |
| 613 | ApprovalMode::Suggest, |
| 614 | ); |
| 615 | let build = engine |
| 616 | .build_turn_tool_registry_and_catalog( |
| 617 | &policy, |
| 618 | &[], |
| 619 | None, |
| 620 | SubAgentWiring::Inert, |
| 621 | McpAccess::PassiveSnapshot, |
| 622 | TurnRouteContext { |
| 623 | provider: route.identity.provider, |
| 624 | model: route.model.clone(), |
| 625 | capabilities: route.candidate.capabilities(), |
| 626 | limits: crate::route_budget::known_route_limits(route.candidate.limits()), |
| 627 | client: Some(route.client), |
| 628 | api_config: route.config, |
| 629 | locale_tag: engine.config.locale_tag.clone(), |
| 630 | role_models: engine.subagent_role_models(), |
| 631 | auto_model: false, |
| 632 | reasoning_effort: planned.effective_reasoning_effort, |
| 633 | reasoning_effort_auto: planned.auto_controls_reasoning, |
| 634 | }, |
| 635 | "", |
| 636 | ) |
| 637 | .await; |
| 638 | assert!( |
| 639 | build |
| 640 | .surface |
| 641 | .catalog |
| 642 | .iter() |
| 643 | .any(|tool| tool.name == "agent"), |
| 644 | "the planned route client must make sub-agent tools available" |
| 645 | ); |
| 646 | assert_eq!( |
| 647 | build.subagent_runtime_model.as_deref(), |
| 648 | Some(planned_model.as_str()), |
| 649 | "the child runtime must carry the planned route model" |
| 650 | ); |
| 651 | } |
| 652 | |
| 653 | /// Auto routing with no hypothetical prompt: every route-derived fact is |
| 654 | /// structurally absent, and the flag is never cleared just because the |
| 655 | /// session happens to have an installed route. |
| 656 | #[tokio::test] |
| 657 | async fn auto_route_without_a_prompt_omits_every_final_fact() { |
| 658 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 659 | let (mut engine, _handle) = Engine::new( |
| 660 | EngineConfig { |
| 661 | workspace: tmp.path().to_path_buf(), |
| 662 | ..Default::default() |
| 663 | }, |
| 664 | &crate::config::Config::default(), |
| 665 | ); |
| 666 | |
| 667 | let manifest = engine |
| 668 | .build_request_manifest(PreviewRequestInputs { |
| 669 | mode: AppMode::Agent, |
| 670 | allow_shell: false, |
| 671 | trust_mode: false, |
| 672 | auto_approve: false, |
| 673 | approval_mode: ApprovalMode::Suggest, |
| 674 | allowed_tools: None, |
| 675 | dynamic_tools: Vec::new(), |
| 676 | provenance: UserInputProvenance::ExternalUser, |
| 677 | requested_model: "auto".to_string(), |
| 678 | requested_reasoning: "auto".to_string(), |
| 679 | auto_model: true, |
| 680 | hypothetical_prompt_supplied: false, |
| 681 | next_turn: None, |
| 682 | unresolved: PreviewUnresolved::AutoRouteNeedsPrompt, |
| 683 | }) |
| 684 | .await; |
| 685 | |
| 686 | assert!(manifest.route.exact().is_none()); |
| 687 | assert!(manifest.tools.exact().is_none()); |
| 688 | assert!(manifest.body.exact().is_none()); |
| 689 | assert_eq!(manifest.session.requested_model.as_str(), "auto"); |
| 690 | assert!(manifest.session.auto_model_routing); |
| 691 | assert!(!manifest.session.hypothetical_prompt_supplied); |
| 692 | |
| 693 | let json = manifest.to_json(); |
| 694 | for forbidden in [ |
| 695 | "provider_id", |
| 696 | "wire_model", |
| 697 | "endpoint_fingerprint", |
| 698 | "body_sha256", |
| 699 | "tool_surface_budget", |
| 700 | "billing", |
| 701 | ] { |
| 702 | assert!(!json.contains(forbidden), "{forbidden} leaked:\n{json}"); |
| 703 | } |
| 704 | } |
| 705 | |
| 706 | fn deepseek_config() -> crate::config::Config { |
| 707 | let providers = crate::config::ProvidersConfig { |
| 708 | deepseek: crate::config::ProviderConfig { |
| 709 | api_key: Some("sk-test-deepseek".to_string()), |
| 710 | model: Some("deepseek-chat".to_string()), |
| 711 | ..crate::config::ProviderConfig::default() |
| 712 | }, |
| 713 | ..crate::config::ProvidersConfig::default() |
| 714 | }; |
| 715 | crate::config::Config { |
| 716 | provider: Some("deepseek".to_string()), |
| 717 | providers: Some(providers), |
| 718 | ..crate::config::Config::default() |
| 719 | } |
| 720 | } |
| 721 | |
| 722 | fn deepseek_identity() -> crate::config::ProviderIdentity { |
| 723 | crate::config::ProviderIdentity { |
| 724 | provider: ApiProvider::Deepseek, |
| 725 | key: "deepseek".to_string(), |
| 726 | exact_id: None, |
| 727 | migrated_legacy_ollama_cloud_route: false, |
| 728 | } |
| 729 | } |
| 730 | |
| 731 | /// Run the *production* route planner, provider-free: with `auto_model` |
| 732 | /// the classifier short-circuits to the inventory heuristic under `cfg!(test)`. |
| 733 | async fn plan( |
| 734 | config: &crate::config::Config, |
| 735 | identity: &crate::config::ProviderIdentity, |
| 736 | auto_model: bool, |
| 737 | prompt: &str, |
| 738 | ) -> crate::turn_route_plan::PlannedTurnRoute { |
| 739 | plan_for( |
| 740 | config, |
| 741 | identity, |
| 742 | ApiProvider::Deepseek, |
| 743 | "deepseek-chat", |
| 744 | auto_model, |
| 745 | prompt, |
| 746 | ) |
| 747 | .await |
| 748 | } |
| 749 | |
| 750 | async fn plan_for( |
| 751 | config: &crate::config::Config, |
| 752 | identity: &crate::config::ProviderIdentity, |
| 753 | provider: ApiProvider, |
| 754 | model: &str, |
| 755 | auto_model: bool, |
| 756 | prompt: &str, |
| 757 | ) -> crate::turn_route_plan::PlannedTurnRoute { |
| 758 | plan_with_reasoning( |
| 759 | config, |
| 760 | identity, |
| 761 | provider, |
| 762 | model, |
| 763 | auto_model, |
| 764 | if auto_model { |
| 765 | crate::reasoning_preference::ReasoningEffort::Auto |
| 766 | } else { |
| 767 | crate::reasoning_preference::ReasoningEffort::High |
| 768 | }, |
| 769 | prompt, |
| 770 | ) |
| 771 | .await |
| 772 | } |
| 773 | |
| 774 | /// `plan_for` with the requested reasoning tier under test control. The |
| 775 | /// exact-route matrix needs `off` to observe a route that normalizes it |
| 776 | /// (direct Moonshot K3 sends `low`). |
| 777 | async fn plan_with_reasoning( |
| 778 | config: &crate::config::Config, |
| 779 | identity: &crate::config::ProviderIdentity, |
| 780 | provider: ApiProvider, |
| 781 | model: &str, |
| 782 | auto_model: bool, |
| 783 | reasoning_effort: crate::reasoning_preference::ReasoningEffort, |
| 784 | prompt: &str, |
| 785 | ) -> crate::turn_route_plan::PlannedTurnRoute { |
| 786 | crate::turn_route_plan::plan_turn_route(crate::turn_route_plan::TurnRoutePlanRequest { |
| 787 | route_config: config, |
| 788 | app_route_identity: identity, |
| 789 | api_provider: provider, |
| 790 | app_model: model, |
| 791 | auto_model, |
| 792 | reasoning_effort, |
| 793 | mode: AppMode::Agent, |
| 794 | content: prompt, |
| 795 | auto_router_context: "", |
| 796 | should_auto_resolve: auto_model, |
| 797 | allow_auto_router_response_cache: false, |
| 798 | preflight_required: false, |
| 799 | auto_compact_user_configured: false, |
| 800 | auto_compact: true, |
| 801 | auto_compact_threshold_percent: 80.0, |
| 802 | }) |
| 803 | .await |
| 804 | .expect("the shared planner resolves a configured route") |
| 805 | } |
| 806 | |
| 807 | fn preview_engine(config: &crate::config::Config) -> (Engine, EngineHandle, tempfile::TempDir) { |
| 808 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 809 | let (engine, handle) = Engine::new( |
| 810 | EngineConfig { |
| 811 | workspace: tmp.path().to_path_buf(), |
| 812 | ..Default::default() |
| 813 | }, |
| 814 | config, |
| 815 | ); |
| 816 | (engine, handle, tmp) |
| 817 | } |
| 818 | |
| 819 | fn wire_preview_engine(config: &crate::config::Config) -> (Engine, tempfile::TempDir) { |
| 820 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 821 | let (mut engine, _handle) = Engine::new( |
| 822 | EngineConfig { |
| 823 | workspace: tmp.path().to_path_buf(), |
| 824 | max_steps: 1, |
| 825 | snapshots_enabled: false, |
| 826 | terminal_chrome_enabled: false, |
| 827 | ..Default::default() |
| 828 | }, |
| 829 | config, |
| 830 | ); |
| 831 | engine.config.features.disable(Feature::Mcp); |
| 832 | engine.config.subagents_enabled = false; |
| 833 | (engine, tmp) |
| 834 | } |
| 835 | |
| 836 | fn inputs( |
| 837 | auto_model: bool, |
| 838 | planned: Option<crate::turn_route_plan::PlannedTurnRoute>, |
| 839 | prompt: &str, |
| 840 | ) -> PreviewRequestInputs { |
| 841 | PreviewRequestInputs { |
| 842 | mode: AppMode::Agent, |
| 843 | allow_shell: false, |
| 844 | trust_mode: false, |
| 845 | auto_approve: false, |
| 846 | approval_mode: ApprovalMode::Suggest, |
| 847 | allowed_tools: None, |
| 848 | dynamic_tools: Vec::new(), |
| 849 | provenance: UserInputProvenance::ExternalUser, |
| 850 | requested_model: if auto_model { |
| 851 | "auto".to_string() |
| 852 | } else { |
| 853 | "deepseek-chat".to_string() |
| 854 | }, |
| 855 | requested_reasoning: if auto_model { "auto" } else { "high" }.to_string(), |
| 856 | auto_model, |
| 857 | hypothetical_prompt_supplied: true, |
| 858 | next_turn: planned.map(|planned| { |
| 859 | let prompt_context = NextTurnPromptContext::for_planned_turn( |
| 860 | planned.route.identity.provider, |
| 861 | planned.route.model.clone(), |
| 862 | crate::route_budget::known_route_limits(planned.route.candidate.limits()), |
| 863 | AppMode::Agent, |
| 864 | None, |
| 865 | GoalStatus::Active, |
| 866 | None, |
| 867 | false, |
| 868 | None, |
| 869 | ); |
| 870 | Box::new(PreviewNextTurn { |
| 871 | content: prompt.to_string(), |
| 872 | route: Box::new(planned.route), |
| 873 | prompt_context, |
| 874 | reasoning_effort: planned.effective_reasoning_effort, |
| 875 | reasoning_effort_auto: planned.auto_controls_reasoning, |
| 876 | auto_route_source: planned |
| 877 | .auto_selection |
| 878 | .as_ref() |
| 879 | .map(|selection| selection.source.label().to_string()), |
| 880 | routing_source: planned.routing_source, |
| 881 | compaction: planned.compaction, |
| 882 | }) |
| 883 | }), |
| 884 | unresolved: PreviewUnresolved::NoPrompt, |
| 885 | } |
| 886 | } |
| 887 | |
| 888 | /// Typed controls for the preview/wire parity fixture. Defaults mirror the |
| 889 | /// ordinary active DeepSeek turn; individual tests override only the |
| 890 | /// production context they are proving. |
| 891 | struct PreviewWireFixture { |
| 892 | goal_objective: Option<String>, |
| 893 | goal_status: GoalStatus, |
| 894 | translation_enabled: bool, |
| 895 | verbosity: Option<String>, |
| 896 | requested_model: Option<String>, |
| 897 | requested_reasoning: Option<String>, |
| 898 | } |
| 899 | |
| 900 | impl Default for PreviewWireFixture { |
| 901 | fn default() -> Self { |
| 902 | Self { |
| 903 | goal_objective: None, |
| 904 | goal_status: GoalStatus::Active, |
| 905 | translation_enabled: false, |
| 906 | verbosity: None, |
| 907 | requested_model: None, |
| 908 | requested_reasoning: None, |
| 909 | } |
| 910 | } |
| 911 | } |
| 912 | |
| 913 | async fn assert_preview_matches_first_wire_body( |
| 914 | engine: &mut Engine, |
| 915 | server: &wiremock::MockServer, |
| 916 | planned: crate::turn_route_plan::PlannedTurnRoute, |
| 917 | prompt: &str, |
| 918 | fixture: PreviewWireFixture, |
| 919 | ) -> (RequestManifest, serde_json::Value) { |
| 920 | let PreviewWireFixture { |
| 921 | goal_objective, |
| 922 | goal_status, |
| 923 | translation_enabled, |
| 924 | verbosity, |
| 925 | requested_model, |
| 926 | requested_reasoning, |
| 927 | } = fixture; |
| 928 | let production_route = planned.route.clone(); |
| 929 | let compaction = planned.compaction.clone(); |
| 930 | let reasoning_effort = planned.effective_reasoning_effort.clone(); |
| 931 | let reasoning_effort_auto = planned.auto_controls_reasoning; |
| 932 | let mut preview_inputs = inputs(false, Some(planned), prompt); |
| 933 | if let Some(requested_model) = requested_model { |
| 934 | preview_inputs.requested_model = requested_model; |
| 935 | } |
| 936 | if let Some(requested_reasoning) = requested_reasoning { |
| 937 | preview_inputs.requested_reasoning = requested_reasoning; |
| 938 | } |
| 939 | let next = preview_inputs.next_turn.as_mut().expect("planned preview"); |
| 940 | next.prompt_context = NextTurnPromptContext::for_planned_turn( |
| 941 | production_route.identity.provider, |
| 942 | production_route.model.clone(), |
| 943 | crate::route_budget::known_route_limits(production_route.candidate.limits()), |
| 944 | AppMode::Agent, |
| 945 | goal_objective.clone(), |
| 946 | goal_status, |
| 947 | None, |
| 948 | translation_enabled, |
| 949 | verbosity.clone(), |
| 950 | ); |
| 951 | let manifest = engine.build_request_manifest(preview_inputs).await; |
| 952 | let preview_hash = manifest |
| 953 | .body |
| 954 | .exact() |
| 955 | .expect("preview body is exact") |
| 956 | .body_sha256 |
| 957 | .clone(); |
| 958 | |
| 959 | let _ = engine |
| 960 | .handle_send_message(TurnSpec { |
| 961 | content: prompt.to_string(), |
| 962 | mode: AppMode::Agent, |
| 963 | route: Box::new(production_route), |
| 964 | compaction: Box::new(compaction), |
| 965 | initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()), |
| 966 | goal_objective, |
| 967 | goal_token_budget: None, |
| 968 | goal_status, |
| 969 | reasoning_effort, |
| 970 | reasoning_effort_auto, |
| 971 | auto_model: false, |
| 972 | allow_shell: false, |
| 973 | trust_mode: false, |
| 974 | auto_approve: false, |
| 975 | approval_mode: ApprovalMode::Suggest, |
| 976 | translation_enabled, |
| 977 | allowed_tools: None, |
| 978 | dynamic_tools: Vec::new(), |
| 979 | hook_executor: None, |
| 980 | verbosity, |
| 981 | provenance: UserInputProvenance::ExternalUser, |
| 982 | images: Vec::new(), |
| 983 | max_output_tokens: None, |
| 984 | }) |
| 985 | .await; |
| 986 | |
| 987 | let requests = server |
| 988 | .received_requests() |
| 989 | .await |
| 990 | .expect("wire mock records requests"); |
| 991 | assert_eq!(requests.len(), 1, "the fixture must make one provider call"); |
| 992 | let first_wire_body: serde_json::Value = |
| 993 | serde_json::from_slice(&requests[0].body).expect("first HTTP body is JSON"); |
| 994 | let first_wire_hash = |
| 995 | crate::hashing::sha256_hex(crate::client::canonical_json(&first_wire_body).as_bytes()); |
| 996 | assert_eq!( |
| 997 | preview_hash, first_wire_hash, |
| 998 | "preview hash must match the body captured at the HTTP boundary" |
| 999 | ); |
| 1000 | (manifest, first_wire_body) |
| 1001 | } |
| 1002 | |
| 1003 | #[tokio::test] |
| 1004 | async fn graph_backed_todo_is_not_reinjected_into_the_first_http_body() { |
| 1005 | use wiremock::matchers::method; |
| 1006 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 1007 | |
| 1008 | let server = MockServer::start().await; |
| 1009 | Mock::given(method("POST")) |
| 1010 | .respond_with( |
| 1011 | ResponseTemplate::new(200) |
| 1012 | .insert_header("content-type", "text/event-stream") |
| 1013 | .set_body_string("data: [DONE]\n\n"), |
| 1014 | ) |
| 1015 | .mount(&server) |
| 1016 | .await; |
| 1017 | let mut config = deepseek_config(); |
| 1018 | config |
| 1019 | .providers |
| 1020 | .as_mut() |
| 1021 | .expect("providers") |
| 1022 | .deepseek |
| 1023 | .base_url = Some(server.uri()); |
| 1024 | let identity = deepseek_identity(); |
| 1025 | let (mut engine, _tmp) = wire_preview_engine(&config); |
| 1026 | let graph_todos = crate::tools::todo::TodoListSnapshot { |
| 1027 | items: vec![crate::tools::todo::TodoItem { |
| 1028 | id: 1, |
| 1029 | content: "preserve this graph-authoritative Work item".to_string(), |
| 1030 | status: crate::tools::todo::TodoStatus::InProgress, |
| 1031 | }], |
| 1032 | completion_pct: 0, |
| 1033 | in_progress_id: Some(1), |
| 1034 | }; |
| 1035 | let work = crate::work_graph::new_shared_work_runtime( |
| 1036 | engine.config.todos.clone(), |
| 1037 | engine.config.plan_state.clone(), |
| 1038 | ); |
| 1039 | work.restore( |
| 1040 | "preview-graph-work", |
| 1041 | None, |
| 1042 | &graph_todos, |
| 1043 | &crate::tools::plan::PlanSnapshot::default(), |
| 1044 | ) |
| 1045 | .expect("restore graph-backed Work state"); |
| 1046 | *engine.config.todos.lock().await = crate::tools::todo::TodoList::new(); |
| 1047 | assert!( |
| 1048 | engine.config.todos.lock().await.snapshot().is_empty(), |
| 1049 | "legacy projection is intentionally stale for this authority test" |
| 1050 | ); |
| 1051 | engine.config.runtime_services.work = Some(work); |
| 1052 | |
| 1053 | let prompt = "inspect the request without restating the To-do list"; |
| 1054 | let planned = plan(&config, &identity, false, prompt).await; |
| 1055 | let (_, first_wire_body) = assert_preview_matches_first_wire_body( |
| 1056 | &mut engine, |
| 1057 | &server, |
| 1058 | planned, |
| 1059 | prompt, |
| 1060 | PreviewWireFixture::default(), |
| 1061 | ) |
| 1062 | .await; |
| 1063 | let body_text = first_wire_body.to_string(); |
| 1064 | assert!( |
| 1065 | !body_text.contains("<codewhale:work_state>") |
| 1066 | && !body_text.contains("preserve this graph-authoritative Work item"), |
| 1067 | "provider requests must not receive a synthetic per-step To-do tail: {body_text}" |
| 1068 | ); |
| 1069 | } |
| 1070 | |
| 1071 | #[tokio::test] |
| 1072 | async fn exhausted_active_goal_remains_previewable() { |
| 1073 | let config = deepseek_config(); |
| 1074 | let identity = deepseek_identity(); |
| 1075 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 1076 | engine.config.features.disable(Feature::Mcp); |
| 1077 | sync_goal_state_from_host( |
| 1078 | &engine.config.goal_state, |
| 1079 | Some("finish the release"), |
| 1080 | Some(100), |
| 1081 | GoalStatus::Active, |
| 1082 | ); |
| 1083 | engine |
| 1084 | .config |
| 1085 | .goal_state |
| 1086 | .lock() |
| 1087 | .expect("goal state") |
| 1088 | .record_usage(100, 0); |
| 1089 | |
| 1090 | let prompt = "continue the release"; |
| 1091 | let planned = plan(&config, &identity, false, prompt).await; |
| 1092 | let manifest = engine |
| 1093 | .build_request_manifest(inputs(false, Some(planned), prompt)) |
| 1094 | .await; |
| 1095 | assert!(manifest.route.exact().is_some()); |
| 1096 | assert!(manifest.tools.exact().is_some()); |
| 1097 | assert!(manifest.body.exact().is_some()); |
| 1098 | } |
| 1099 | |
| 1100 | #[tokio::test] |
| 1101 | async fn resumed_goal_with_raised_budget_becomes_previewable_again() { |
| 1102 | let config = deepseek_config(); |
| 1103 | let identity = deepseek_identity(); |
| 1104 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 1105 | engine.config.features.disable(Feature::Mcp); |
| 1106 | sync_goal_state_from_host( |
| 1107 | &engine.config.goal_state, |
| 1108 | Some("finish the release"), |
| 1109 | Some(100), |
| 1110 | GoalStatus::Active, |
| 1111 | ); |
| 1112 | { |
| 1113 | let mut state = engine.config.goal_state.lock().expect("goal state"); |
| 1114 | state.record_usage(100, 0); |
| 1115 | state |
| 1116 | .mark_paused(GoalPauseReason::BudgetLimit) |
| 1117 | .expect("pause goal"); |
| 1118 | } |
| 1119 | sync_goal_state_from_host( |
| 1120 | &engine.config.goal_state, |
| 1121 | Some("finish the release"), |
| 1122 | Some(200), |
| 1123 | GoalStatus::Active, |
| 1124 | ); |
| 1125 | |
| 1126 | let prompt = "continue under the raised budget"; |
| 1127 | let planned = plan(&config, &identity, false, prompt).await; |
| 1128 | let manifest = engine |
| 1129 | .build_request_manifest(inputs(false, Some(planned), prompt)) |
| 1130 | .await; |
| 1131 | assert!(manifest.body.exact().is_some()); |
| 1132 | } |
| 1133 | |
| 1134 | #[tokio::test] |
| 1135 | async fn lowering_active_goal_budget_below_used_tokens_keeps_preview_open() { |
| 1136 | let config = deepseek_config(); |
| 1137 | let identity = deepseek_identity(); |
| 1138 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 1139 | engine.config.features.disable(Feature::Mcp); |
| 1140 | sync_goal_state_from_host( |
| 1141 | &engine.config.goal_state, |
| 1142 | Some("finish the release"), |
| 1143 | Some(200), |
| 1144 | GoalStatus::Active, |
| 1145 | ); |
| 1146 | engine |
| 1147 | .config |
| 1148 | .goal_state |
| 1149 | .lock() |
| 1150 | .expect("goal state") |
| 1151 | .record_usage(100, 0); |
| 1152 | sync_goal_state_from_host( |
| 1153 | &engine.config.goal_state, |
| 1154 | Some("finish the release"), |
| 1155 | Some(50), |
| 1156 | GoalStatus::Active, |
| 1157 | ); |
| 1158 | |
| 1159 | let prompt = "continue after lowering the budget"; |
| 1160 | let planned = plan(&config, &identity, false, prompt).await; |
| 1161 | let manifest = engine |
| 1162 | .build_request_manifest(inputs(false, Some(planned), prompt)) |
| 1163 | .await; |
| 1164 | assert!(manifest.route.exact().is_some()); |
| 1165 | assert!(manifest.tools.exact().is_some()); |
| 1166 | assert!(manifest.body.exact().is_some()); |
| 1167 | } |
| 1168 | |
| 1169 | #[tokio::test] |
| 1170 | async fn translation_prompt_context_matches_captured_first_production_body() { |
| 1171 | use wiremock::matchers::method; |
| 1172 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 1173 | |
| 1174 | let server = MockServer::start().await; |
| 1175 | Mock::given(method("POST")) |
| 1176 | .respond_with( |
| 1177 | ResponseTemplate::new(200) |
| 1178 | .insert_header("content-type", "text/event-stream") |
| 1179 | .set_body_string("data: [DONE]\n\n"), |
| 1180 | ) |
| 1181 | .mount(&server) |
| 1182 | .await; |
| 1183 | let mut config = deepseek_config(); |
| 1184 | config |
| 1185 | .providers |
| 1186 | .as_mut() |
| 1187 | .expect("providers") |
| 1188 | .deepseek |
| 1189 | .base_url = Some(server.uri()); |
| 1190 | let identity = deepseek_identity(); |
| 1191 | let (mut engine, _tmp) = wire_preview_engine(&config); |
| 1192 | engine.config.translation_enabled = false; |
| 1193 | let planned = plan(&config, &identity, false, "/translate explain this").await; |
| 1194 | let _ = assert_preview_matches_first_wire_body( |
| 1195 | &mut engine, |
| 1196 | &server, |
| 1197 | planned, |
| 1198 | "/translate explain this", |
| 1199 | PreviewWireFixture { |
| 1200 | translation_enabled: true, |
| 1201 | verbosity: Some("concise".to_string()), |
| 1202 | ..Default::default() |
| 1203 | }, |
| 1204 | ) |
| 1205 | .await; |
| 1206 | } |
| 1207 | |
| 1208 | #[tokio::test] |
| 1209 | async fn paused_detach_goal_context_matches_captured_first_production_body() { |
| 1210 | use wiremock::matchers::method; |
| 1211 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 1212 | |
| 1213 | let server = MockServer::start().await; |
| 1214 | Mock::given(method("POST")) |
| 1215 | .respond_with( |
| 1216 | ResponseTemplate::new(200) |
| 1217 | .insert_header("content-type", "text/event-stream") |
| 1218 | .set_body_string("data: [DONE]\n\n"), |
| 1219 | ) |
| 1220 | .mount(&server) |
| 1221 | .await; |
| 1222 | let mut config = deepseek_config(); |
| 1223 | config |
| 1224 | .providers |
| 1225 | .as_mut() |
| 1226 | .expect("providers") |
| 1227 | .deepseek |
| 1228 | .base_url = Some(server.uri()); |
| 1229 | let identity = deepseek_identity(); |
| 1230 | let (mut engine, _tmp) = wire_preview_engine(&config); |
| 1231 | engine.config.goal_objective = Some("stale paused objective".to_string()); |
| 1232 | sync_goal_state_from_host( |
| 1233 | &engine.config.goal_state, |
| 1234 | Some("stale paused objective"), |
| 1235 | None, |
| 1236 | GoalStatus::Active, |
| 1237 | ); |
| 1238 | let prompt = "answer only this new question\n\nCodewhale paused custom slash command context:\nThe user is not resuming that paused command."; |
| 1239 | let planned = plan(&config, &identity, false, prompt).await; |
| 1240 | let (_, first_wire_body) = assert_preview_matches_first_wire_body( |
| 1241 | &mut engine, |
| 1242 | &server, |
| 1243 | planned, |
| 1244 | prompt, |
| 1245 | PreviewWireFixture::default(), |
| 1246 | ) |
| 1247 | .await; |
| 1248 | assert!( |
| 1249 | !first_wire_body |
| 1250 | .to_string() |
| 1251 | .contains("stale paused objective"), |
| 1252 | "detached paused goal leaked onto the first wire body" |
| 1253 | ); |
| 1254 | } |
| 1255 | |
| 1256 | #[tokio::test] |
| 1257 | async fn anthropic_preview_matches_the_first_native_messages_wire_body() { |
| 1258 | use wiremock::matchers::{method, path}; |
| 1259 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 1260 | |
| 1261 | let server = MockServer::start().await; |
| 1262 | Mock::given(method("POST")) |
| 1263 | .and(path("/v1/messages")) |
| 1264 | .respond_with( |
| 1265 | ResponseTemplate::new(200) |
| 1266 | .insert_header("content-type", "text/event-stream") |
| 1267 | .set_body_string("data: {\"type\":\"message_stop\"}\n\n"), |
| 1268 | ) |
| 1269 | .mount(&server) |
| 1270 | .await; |
| 1271 | let model = "claude-sonnet-4-6"; |
| 1272 | let config = crate::config::Config { |
| 1273 | provider: Some("anthropic".to_string()), |
| 1274 | providers: Some(crate::config::ProvidersConfig { |
| 1275 | anthropic: crate::config::ProviderConfig { |
| 1276 | api_key: Some("test-anthropic-key".to_string()), |
| 1277 | base_url: Some(server.uri()), |
| 1278 | model: Some(model.to_string()), |
| 1279 | ..crate::config::ProviderConfig::default() |
| 1280 | }, |
| 1281 | ..crate::config::ProvidersConfig::default() |
| 1282 | }), |
| 1283 | ..crate::config::Config::default() |
| 1284 | }; |
| 1285 | let identity = crate::config::ProviderIdentity { |
| 1286 | provider: ApiProvider::Anthropic, |
| 1287 | key: "anthropic".to_string(), |
| 1288 | exact_id: None, |
| 1289 | migrated_legacy_ollama_cloud_route: false, |
| 1290 | }; |
| 1291 | let (mut engine, _tmp) = wire_preview_engine(&config); |
| 1292 | let prompt = "inspect the native Messages payload"; |
| 1293 | let planned = plan_for( |
| 1294 | &config, |
| 1295 | &identity, |
| 1296 | ApiProvider::Anthropic, |
| 1297 | model, |
| 1298 | false, |
| 1299 | prompt, |
| 1300 | ) |
| 1301 | .await; |
| 1302 | let (_, first_wire_body) = assert_preview_matches_first_wire_body( |
| 1303 | &mut engine, |
| 1304 | &server, |
| 1305 | planned, |
| 1306 | prompt, |
| 1307 | PreviewWireFixture::default(), |
| 1308 | ) |
| 1309 | .await; |
| 1310 | |
| 1311 | assert!(first_wire_body.get("system").is_some()); |
| 1312 | assert!(first_wire_body.get("messages").is_some()); |
| 1313 | assert!(first_wire_body.get("input").is_none()); |
| 1314 | } |
| 1315 | |
| 1316 | // --------------------------------------------------------------------- |
| 1317 | // #4707 — provider-free exact-route request/receipt matrix. |
| 1318 | // |
| 1319 | // The four route wire-truths are already pinned at the client boundary |
| 1320 | // (`client.rs`, `client/chat.rs`). What was missing is the *join*: that the |
| 1321 | // manifest a user reads from `/preview-request` describes the very bytes |
| 1322 | // those routes put on the wire. Each case below runs the production |
| 1323 | // planner, previews, then sends one turn through a local capture server |
| 1324 | // with the semantic endpoint left exact, and asserts the manifest against |
| 1325 | // the captured body — hash, sizes, route facts, and the requested→effective |
| 1326 | // reasoning triple. |
| 1327 | // --------------------------------------------------------------------- |
| 1328 | |
| 1329 | /// One exact provider route in the matrix. |
| 1330 | struct MatrixRoute { |
| 1331 | /// Test-facing name; also the failure-message prefix. |
| 1332 | name: &'static str, |
| 1333 | provider: ApiProvider, |
| 1334 | provider_key: &'static str, |
| 1335 | base_url: &'static str, |
| 1336 | model: &'static str, |
| 1337 | requested_reasoning: crate::reasoning_preference::ReasoningEffort, |
| 1338 | requested_reasoning_label: &'static str, |
| 1339 | /// Reasoning-control keys the manifest must report, in receipt order. |
| 1340 | expect_control_keys: &'static [&'static str], |
| 1341 | /// Effort actually on the wire — `None` when the route publishes a |
| 1342 | /// thinking toggle with no granularity. Never a fabricated tier. |
| 1343 | expect_wire_effort: Option<&'static str>, |
| 1344 | expect_wire_effort_source: Option<&'static str>, |
| 1345 | /// The output-cap key this route writes, and the one it must not. |
| 1346 | expect_output_cap_key: &'static str, |
| 1347 | expect_absent_output_cap_key: &'static str, |
| 1348 | } |
| 1349 | |
| 1350 | fn glm_5_2_zai_coding() -> MatrixRoute { |
| 1351 | MatrixRoute { |
| 1352 | name: "GLM-5.2 @ Z.ai coding", |
| 1353 | provider: ApiProvider::Zai, |
| 1354 | provider_key: "zai", |
| 1355 | base_url: crate::config::DEFAULT_ZAI_BASE_URL, |
| 1356 | model: crate::config::ZAI_GLM_5_2_MODEL, |
| 1357 | requested_reasoning: crate::reasoning_preference::ReasoningEffort::High, |
| 1358 | requested_reasoning_label: "high", |
| 1359 | expect_control_keys: &["reasoning_effort", "thinking"], |
| 1360 | expect_wire_effort: Some("high"), |
| 1361 | expect_wire_effort_source: Some("reasoning_effort"), |
| 1362 | expect_output_cap_key: "max_tokens", |
| 1363 | expect_absent_output_cap_key: "max_completion_tokens", |
| 1364 | } |
| 1365 | } |
| 1366 | |
| 1367 | fn glm_5_turbo_zai() -> MatrixRoute { |
| 1368 | MatrixRoute { |
| 1369 | name: "GLM-5-Turbo @ Z.ai", |
| 1370 | provider: ApiProvider::Zai, |
| 1371 | provider_key: "zai", |
| 1372 | base_url: crate::config::DEFAULT_ZAI_BASE_URL, |
| 1373 | model: crate::config::ZAI_GLM_5_TURBO_MODEL, |
| 1374 | requested_reasoning: crate::reasoning_preference::ReasoningEffort::High, |
| 1375 | requested_reasoning_label: "high", |
| 1376 | // No invented granularity: the toggle ships, the tier does not. |
| 1377 | expect_control_keys: &["thinking"], |
| 1378 | expect_wire_effort: None, |
| 1379 | expect_wire_effort_source: None, |
| 1380 | expect_output_cap_key: "max_tokens", |
| 1381 | expect_absent_output_cap_key: "max_completion_tokens", |
| 1382 | } |
| 1383 | } |
| 1384 | |
| 1385 | fn kimi_k3_moonshot_direct() -> MatrixRoute { |
| 1386 | MatrixRoute { |
| 1387 | name: "kimi-k3 @ api.moonshot.ai", |
| 1388 | provider: ApiProvider::Moonshot, |
| 1389 | provider_key: "moonshot", |
| 1390 | base_url: crate::config::DEFAULT_MOONSHOT_BASE_URL, |
| 1391 | model: crate::config::MOONSHOT_KIMI_K3_MODEL, |
| 1392 | // The visible normalization: `off` is not a tier this route has. |
| 1393 | requested_reasoning: crate::reasoning_preference::ReasoningEffort::Off, |
| 1394 | requested_reasoning_label: "off", |
| 1395 | expect_control_keys: &["reasoning_effort"], |
| 1396 | expect_wire_effort: Some("low"), |
| 1397 | expect_wire_effort_source: Some("reasoning_effort"), |
| 1398 | expect_output_cap_key: "max_completion_tokens", |
| 1399 | expect_absent_output_cap_key: "max_tokens", |
| 1400 | } |
| 1401 | } |
| 1402 | |
| 1403 | fn k3_kimi_code() -> MatrixRoute { |
| 1404 | MatrixRoute { |
| 1405 | name: "k3 @ api.kimi.com/coding/v1", |
| 1406 | provider: ApiProvider::Moonshot, |
| 1407 | provider_key: "moonshot", |
| 1408 | base_url: crate::config::DEFAULT_KIMI_CODE_BASE_URL, |
| 1409 | model: crate::config::KIMI_CODE_K3_MODEL, |
| 1410 | requested_reasoning: crate::reasoning_preference::ReasoningEffort::Off, |
| 1411 | requested_reasoning_label: "off", |
| 1412 | expect_control_keys: &["thinking"], |
| 1413 | expect_wire_effort: Some("low"), |
| 1414 | expect_wire_effort_source: Some("thinking.effort"), |
| 1415 | expect_output_cap_key: "max_tokens", |
| 1416 | expect_absent_output_cap_key: "max_completion_tokens", |
| 1417 | } |
| 1418 | } |
| 1419 | |
| 1420 | fn minimax_m3() -> MatrixRoute { |
| 1421 | MatrixRoute { |
| 1422 | name: "MiniMax-M3 @ api.minimax.io", |
| 1423 | provider: ApiProvider::Minimax, |
| 1424 | provider_key: "minimax", |
| 1425 | base_url: crate::config::DEFAULT_MINIMAX_BASE_URL, |
| 1426 | model: crate::config::DEFAULT_MINIMAX_MODEL, |
| 1427 | requested_reasoning: crate::reasoning_preference::ReasoningEffort::High, |
| 1428 | requested_reasoning_label: "high", |
| 1429 | expect_control_keys: &["thinking", "reasoning_split"], |
| 1430 | expect_wire_effort: None, |
| 1431 | expect_wire_effort_source: None, |
| 1432 | expect_output_cap_key: "max_completion_tokens", |
| 1433 | expect_absent_output_cap_key: "max_tokens", |
| 1434 | } |
| 1435 | } |
| 1436 | |
| 1437 | fn matrix_routes() -> Vec<MatrixRoute> { |
| 1438 | vec![ |
| 1439 | glm_5_2_zai_coding(), |
| 1440 | glm_5_turbo_zai(), |
| 1441 | kimi_k3_moonshot_direct(), |
| 1442 | k3_kimi_code(), |
| 1443 | minimax_m3(), |
| 1444 | ] |
| 1445 | } |
| 1446 | |
| 1447 | fn matrix_config(route: &MatrixRoute) -> crate::config::Config { |
| 1448 | let entry = crate::config::ProviderConfig { |
| 1449 | api_key: Some(format!("sk-test-{}-matrix-key", route.provider_key)), |
| 1450 | base_url: Some(route.base_url.to_string()), |
| 1451 | model: Some(route.model.to_string()), |
| 1452 | ..crate::config::ProviderConfig::default() |
| 1453 | }; |
| 1454 | let mut providers = crate::config::ProvidersConfig::default(); |
| 1455 | match route.provider { |
| 1456 | ApiProvider::Zai => providers.zai = entry, |
| 1457 | ApiProvider::Moonshot => providers.moonshot = entry, |
| 1458 | ApiProvider::Minimax => providers.minimax = entry, |
| 1459 | other => panic!("{}: unhandled matrix provider {other:?}", route.name), |
| 1460 | } |
| 1461 | crate::config::Config { |
| 1462 | provider: Some(route.provider_key.to_string()), |
| 1463 | providers: Some(providers), |
| 1464 | ..crate::config::Config::default() |
| 1465 | } |
| 1466 | } |
| 1467 | |
| 1468 | fn matrix_identity(route: &MatrixRoute) -> crate::config::ProviderIdentity { |
| 1469 | crate::config::ProviderIdentity { |
| 1470 | provider: route.provider, |
| 1471 | key: route.provider_key.to_string(), |
| 1472 | exact_id: None, |
| 1473 | migrated_legacy_ollama_cloud_route: false, |
| 1474 | } |
| 1475 | } |
| 1476 | |
| 1477 | /// Plan the exact route through production, then redirect only the |
| 1478 | /// *transport* at the local capture server. The endpoint identity the |
| 1479 | /// route shaper reads is untouched, so the captured body is the body |
| 1480 | /// `api.z.ai` / `api.moonshot.ai` / `api.kimi.com` / `api.minimax.io` |
| 1481 | /// would have received. |
| 1482 | async fn matrix_planned_route( |
| 1483 | route: &MatrixRoute, |
| 1484 | config: &crate::config::Config, |
| 1485 | transport_base_url: Option<&str>, |
| 1486 | prompt: &str, |
| 1487 | ) -> crate::turn_route_plan::PlannedTurnRoute { |
| 1488 | let identity = matrix_identity(route); |
| 1489 | let mut planned = plan_with_reasoning( |
| 1490 | config, |
| 1491 | &identity, |
| 1492 | route.provider, |
| 1493 | route.model, |
| 1494 | false, |
| 1495 | route.requested_reasoning, |
| 1496 | prompt, |
| 1497 | ) |
| 1498 | .await; |
| 1499 | if let Some(transport_base_url) = transport_base_url { |
| 1500 | let validated = planned |
| 1501 | .route |
| 1502 | .clone() |
| 1503 | .validate() |
| 1504 | .expect("the matrix route validates into a concrete client"); |
| 1505 | let mut client = validated.client.clone(); |
| 1506 | client.set_test_chat_transport_base_url(transport_base_url.to_string()); |
| 1507 | planned.route = crate::route_runtime::ValidatedRuntimeRoute { |
| 1508 | client, |
| 1509 | ..validated |
| 1510 | } |
| 1511 | .into_resolved(); |
| 1512 | } |
| 1513 | planned |
| 1514 | } |
| 1515 | |
| 1516 | /// Non-system messages on a Chat Completions body — the manifest counts |
| 1517 | /// the system region separately, so `message_count` must exclude it. |
| 1518 | fn non_system_message_count(body: &serde_json::Value) -> usize { |
| 1519 | body.get("messages") |
| 1520 | .and_then(serde_json::Value::as_array) |
| 1521 | .map(|messages| { |
| 1522 | messages |
| 1523 | .iter() |
| 1524 | .filter(|message| { |
| 1525 | message.get("role").and_then(serde_json::Value::as_str) != Some("system") |
| 1526 | }) |
| 1527 | .count() |
| 1528 | }) |
| 1529 | .unwrap_or_default() |
| 1530 | } |
| 1531 | |
| 1532 | async fn assert_matrix_route(route: &MatrixRoute) { |
| 1533 | use wiremock::matchers::method; |
| 1534 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 1535 | |
| 1536 | let name = route.name; |
| 1537 | let server = MockServer::start().await; |
| 1538 | Mock::given(method("POST")) |
| 1539 | .respond_with( |
| 1540 | ResponseTemplate::new(200) |
| 1541 | .insert_header("content-type", "text/event-stream") |
| 1542 | .set_body_string("data: [DONE]\n\n"), |
| 1543 | ) |
| 1544 | .mount(&server) |
| 1545 | .await; |
| 1546 | |
| 1547 | let config = matrix_config(route); |
| 1548 | let (mut engine, _tmp) = wire_preview_engine(&config); |
| 1549 | let prompt = "inspect the exact next request for this route"; |
| 1550 | let uri = server.uri(); |
| 1551 | let planned = matrix_planned_route(route, &config, Some(uri.as_str()), prompt).await; |
| 1552 | let planned_provider = planned.route.identity.provider; |
| 1553 | let planned_base_url = planned.route.candidate.endpoint().base_url.clone(); |
| 1554 | let planned_model = planned.route.model.clone(); |
| 1555 | let expected_wire_model = crate::config::wire_model_for_provider_route( |
| 1556 | planned_provider, |
| 1557 | &planned_base_url, |
| 1558 | &planned_model, |
| 1559 | ); |
| 1560 | assert_eq!( |
| 1561 | planned_base_url.trim_end_matches('/'), |
| 1562 | route.base_url.trim_end_matches('/'), |
| 1563 | "{name}: the planner must keep the exact configured endpoint" |
| 1564 | ); |
| 1565 | |
| 1566 | let (manifest, body) = assert_preview_matches_first_wire_body( |
| 1567 | &mut engine, |
| 1568 | &server, |
| 1569 | planned, |
| 1570 | prompt, |
| 1571 | PreviewWireFixture { |
| 1572 | requested_model: Some(route.model.to_string()), |
| 1573 | requested_reasoning: Some(route.requested_reasoning_label.to_string()), |
| 1574 | ..Default::default() |
| 1575 | }, |
| 1576 | ) |
| 1577 | .await; |
| 1578 | |
| 1579 | // --- Route facts ------------------------------------------------- |
| 1580 | let facts = manifest |
| 1581 | .route |
| 1582 | .exact() |
| 1583 | .expect("a configured fixed route is exact"); |
| 1584 | assert_eq!(facts.provider_id.as_str(), route.provider_key, "{name}"); |
| 1585 | assert_eq!(facts.wire_model.as_str(), expected_wire_model, "{name}"); |
| 1586 | assert_eq!(facts.dialect, "chat-completions", "{name}"); |
| 1587 | assert_eq!(facts.routing_source, "active-fixed-route", "{name}"); |
| 1588 | assert_eq!( |
| 1589 | body.get("model").and_then(serde_json::Value::as_str), |
| 1590 | Some(expected_wire_model.as_str()), |
| 1591 | "{name}: the manifest's wire model must be the model on the wire: {body}" |
| 1592 | ); |
| 1593 | |
| 1594 | // --- Body identity, byte accounting, and size estimates ---------- |
| 1595 | let facts_body = manifest.body.exact().expect("a prompted body is exact"); |
| 1596 | let canonical = crate::client::canonical_json(&body); |
| 1597 | assert_eq!( |
| 1598 | facts_body.body_sha256, |
| 1599 | crate::hashing::sha256_hex(canonical.as_bytes()), |
| 1600 | "{name}: manifest body hash must equal the captured wire body hash" |
| 1601 | ); |
| 1602 | assert_eq!( |
| 1603 | facts_body.body_canonical_json_bytes, |
| 1604 | canonical.len(), |
| 1605 | "{name}: canonical body size must describe the captured body" |
| 1606 | ); |
| 1607 | assert_eq!( |
| 1608 | facts_body.system_canonical_json_bytes |
| 1609 | + facts_body.tool_schema_canonical_json_bytes |
| 1610 | + facts_body.message_canonical_json_bytes |
| 1611 | + facts_body.framing_canonical_json_bytes, |
| 1612 | facts_body.body_canonical_json_bytes, |
| 1613 | "{name}: the four accounting classes must sum to the body" |
| 1614 | ); |
| 1615 | assert!( |
| 1616 | facts_body.system_canonical_json_bytes > 0, |
| 1617 | "{name}: this route sends a system region" |
| 1618 | ); |
| 1619 | assert!( |
| 1620 | facts_body.tool_schema_canonical_json_bytes > 0, |
| 1621 | "{name}: this route sends tool schemas" |
| 1622 | ); |
| 1623 | assert!( |
| 1624 | facts_body.message_canonical_json_bytes > 0, |
| 1625 | "{name}: this route sends messages" |
| 1626 | ); |
| 1627 | assert_eq!( |
| 1628 | facts_body.message_count, |
| 1629 | non_system_message_count(&body), |
| 1630 | "{name}: message_count counts the non-system messages on the wire" |
| 1631 | ); |
| 1632 | assert!( |
| 1633 | facts_body.tool_result_canonical_json_bytes <= facts_body.message_canonical_json_bytes, |
| 1634 | "{name}: tool results are a subset of messages" |
| 1635 | ); |
| 1636 | assert!( |
| 1637 | facts_body.attachment_canonical_json_bytes <= facts_body.message_canonical_json_bytes, |
| 1638 | "{name}: attachments are a subset of messages" |
| 1639 | ); |
| 1640 | assert_eq!( |
| 1641 | facts_body.tool_schema_wire_sha256, |
| 1642 | body.get("tools").map(|tools| { |
| 1643 | crate::hashing::sha256_hex(crate::client::canonical_json(tools).as_bytes()) |
| 1644 | }), |
| 1645 | "{name}: the tool-schema digest must be over the schemas on the wire" |
| 1646 | ); |
| 1647 | assert!( |
| 1648 | facts_body.estimates.system > 0 && facts_body.estimates.tool_schemas > 0, |
| 1649 | "{name}: per-class estimates are derived from the same wire regions" |
| 1650 | ); |
| 1651 | assert!( |
| 1652 | facts_body.estimates.total_conservative > 0, |
| 1653 | "{name}: a whole-body estimate is available" |
| 1654 | ); |
| 1655 | |
| 1656 | // --- Output cap: exactly the key this route writes ---------------- |
| 1657 | let wire_cap = body |
| 1658 | .get(route.expect_output_cap_key) |
| 1659 | .and_then(serde_json::Value::as_u64); |
| 1660 | assert!( |
| 1661 | wire_cap.is_some(), |
| 1662 | "{name}: expected `{}` on the wire: {body}", |
| 1663 | route.expect_output_cap_key |
| 1664 | ); |
| 1665 | assert!( |
| 1666 | body.get(route.expect_absent_output_cap_key).is_none(), |
| 1667 | "{name}: `{}` must not be on the wire: {body}", |
| 1668 | route.expect_absent_output_cap_key |
| 1669 | ); |
| 1670 | assert_eq!( |
| 1671 | facts_body.wire_output_cap_tokens, wire_cap, |
| 1672 | "{name}: the reported output cap is the one literally on the wire" |
| 1673 | ); |
| 1674 | |
| 1675 | // --- requested → effective reasoning ------------------------------ |
| 1676 | assert_eq!( |
| 1677 | manifest.session.requested_model.as_str(), |
| 1678 | route.model, |
| 1679 | "{name}" |
| 1680 | ); |
| 1681 | assert_eq!( |
| 1682 | manifest.session.requested_reasoning.as_str(), |
| 1683 | route.requested_reasoning_label, |
| 1684 | "{name}" |
| 1685 | ); |
| 1686 | assert_eq!( |
| 1687 | facts_body.reasoning_resolution, |
| 1688 | ReasoningResolution::Explicit, |
| 1689 | "{name}: a fixed route with an explicitly requested tier" |
| 1690 | ); |
| 1691 | assert_eq!( |
| 1692 | facts_body.reasoning_wire_control_keys, route.expect_control_keys, |
| 1693 | "{name}: reasoning-control keys, against the captured body {body}" |
| 1694 | ); |
| 1695 | assert_eq!( |
| 1696 | facts_body |
| 1697 | .reasoning_wire_effort |
| 1698 | .as_ref() |
| 1699 | .map(|effort| effort.as_str()), |
| 1700 | route.expect_wire_effort, |
| 1701 | "{name}: wire effort, against the captured body {body}" |
| 1702 | ); |
| 1703 | assert_eq!( |
| 1704 | facts_body.reasoning_wire_effort_source.as_deref(), |
| 1705 | route.expect_wire_effort_source, |
| 1706 | "{name}" |
| 1707 | ); |
| 1708 | // Every reported control key is genuinely present on the wire, and the |
| 1709 | // reported effort is genuinely readable at the reported key path. |
| 1710 | for key in &facts_body.reasoning_wire_control_keys { |
| 1711 | assert!( |
| 1712 | body.get(key).is_some(), |
| 1713 | "{name}: reported control key `{key}` is not on the wire: {body}" |
| 1714 | ); |
| 1715 | } |
| 1716 | match ( |
| 1717 | facts_body.reasoning_wire_effort_source.as_deref(), |
| 1718 | route.expect_wire_effort, |
| 1719 | ) { |
| 1720 | (Some("reasoning_effort"), Some(effort)) => assert_eq!( |
| 1721 | body.get("reasoning_effort") |
| 1722 | .and_then(serde_json::Value::as_str), |
| 1723 | Some(effort), |
| 1724 | "{name}: {body}" |
| 1725 | ), |
| 1726 | (Some(path), Some(effort)) => { |
| 1727 | let pointer = format!("/{}", path.replace('.', "/")); |
| 1728 | assert_eq!( |
| 1729 | body.pointer(&pointer).and_then(serde_json::Value::as_str), |
| 1730 | Some(effort), |
| 1731 | "{name}: {body}" |
| 1732 | ); |
| 1733 | } |
| 1734 | (None, None) => assert!( |
| 1735 | body.get("reasoning_effort").is_none(), |
| 1736 | "{name}: no effort was reported, so none may be on the wire: {body}" |
| 1737 | ), |
| 1738 | (source, effort) => panic!("{name}: inconsistent effort receipt {source:?}/{effort:?}"), |
| 1739 | } |
| 1740 | |
| 1741 | // --- Provider-authoritative usage --------------------------------- |
| 1742 | // A preview describes a request that has not been sent. Unknown stays |
| 1743 | // unknown; it never becomes a zero. |
| 1744 | assert!( |
| 1745 | matches!( |
| 1746 | &facts_body.provider_reported_usage, |
| 1747 | Availability::Unavailable(unavailable) |
| 1748 | if unavailable.reason == UnavailableReason::ProviderRequestNotExecuted |
| 1749 | ), |
| 1750 | "{name}: preview must not claim provider usage" |
| 1751 | ); |
| 1752 | let json = manifest.to_json(); |
| 1753 | assert!( |
| 1754 | !json.contains("\"input_tokens\""), |
| 1755 | "{name}: no fabricated usage counters reach the surface:\n{json}" |
| 1756 | ); |
| 1757 | } |
| 1758 | |
| 1759 | #[tokio::test] |
| 1760 | async fn matrix_glm_5_2_zai_coding_preview_matches_the_first_wire_body() { |
| 1761 | assert_matrix_route(&glm_5_2_zai_coding()).await; |
| 1762 | } |
| 1763 | |
| 1764 | #[tokio::test] |
| 1765 | async fn matrix_glm_5_turbo_zai_preview_matches_the_first_wire_body() { |
| 1766 | assert_matrix_route(&glm_5_turbo_zai()).await; |
| 1767 | } |
| 1768 | |
| 1769 | #[tokio::test] |
| 1770 | async fn matrix_kimi_k3_moonshot_direct_preview_matches_the_first_wire_body() { |
| 1771 | assert_matrix_route(&kimi_k3_moonshot_direct()).await; |
| 1772 | } |
| 1773 | |
| 1774 | #[tokio::test] |
| 1775 | async fn matrix_k3_kimi_code_preview_matches_the_first_wire_body() { |
| 1776 | assert_matrix_route(&k3_kimi_code()).await; |
| 1777 | } |
| 1778 | |
| 1779 | #[tokio::test] |
| 1780 | async fn matrix_minimax_m3_preview_matches_the_first_wire_body() { |
| 1781 | assert_matrix_route(&minimax_m3()).await; |
| 1782 | } |
| 1783 | |
| 1784 | /// The active tool-catalog hash is a *catalog identity*, not a wire fact: |
| 1785 | /// the same catalog under the same posture must hash the same on every |
| 1786 | /// route, however differently each dialect then shapes those schemas. |
| 1787 | /// (Unit-level membership/order/schema sensitivity is pinned by |
| 1788 | /// `active_catalog_hash_tracks_membership_order_and_schema`.) |
| 1789 | #[tokio::test] |
| 1790 | async fn matrix_routes_share_one_active_tool_catalog_hash() { |
| 1791 | let workspace = tempfile::tempdir().expect("tempdir"); |
| 1792 | let prompt = "describe the shared tool catalog"; |
| 1793 | let mut observed: Vec<(&'static str, usize, String, String)> = Vec::new(); |
| 1794 | |
| 1795 | for route in matrix_routes() { |
| 1796 | let config = matrix_config(&route); |
| 1797 | let (mut engine, _handle) = Engine::new( |
| 1798 | EngineConfig { |
| 1799 | workspace: workspace.path().to_path_buf(), |
| 1800 | max_steps: 1, |
| 1801 | snapshots_enabled: false, |
| 1802 | terminal_chrome_enabled: false, |
| 1803 | ..Default::default() |
| 1804 | }, |
| 1805 | &config, |
| 1806 | ); |
| 1807 | engine.config.features.disable(Feature::Mcp); |
| 1808 | engine.config.subagents_enabled = false; |
| 1809 | |
| 1810 | let planned = matrix_planned_route(&route, &config, None, prompt).await; |
| 1811 | let manifest = engine |
| 1812 | .build_request_manifest(inputs(false, Some(planned), prompt)) |
| 1813 | .await; |
| 1814 | let tools = manifest |
| 1815 | .tools |
| 1816 | .exact() |
| 1817 | .expect("MCP is off, so the tool surface is exact"); |
| 1818 | assert!( |
| 1819 | tools.standard_and_full_surfaces_collapsed, |
| 1820 | "{}: this fixture's catalog fits both budgets, so the surface \ |
| 1821 | budget label cannot change catalog membership", |
| 1822 | route.name |
| 1823 | ); |
| 1824 | observed.push(( |
| 1825 | route.name, |
| 1826 | tools.active_tool_count, |
| 1827 | tools.active_tool_catalog_sha256.clone(), |
| 1828 | tools.tool_surface_budget.clone(), |
| 1829 | )); |
| 1830 | } |
| 1831 | |
| 1832 | assert_eq!(observed.len(), 5, "every matrix route is represented"); |
| 1833 | let (first_name, first_count, first_hash, _) = observed[0].clone(); |
| 1834 | for (name, count, hash, _) in &observed { |
| 1835 | assert_eq!( |
| 1836 | *count, first_count, |
| 1837 | "{name} vs {first_name}: the matrix fixture holds the tool surface constant" |
| 1838 | ); |
| 1839 | assert_eq!( |
| 1840 | hash, &first_hash, |
| 1841 | "{name} vs {first_name}: one catalog must hash to one identity across routes" |
| 1842 | ); |
| 1843 | } |
| 1844 | |
| 1845 | // …and the routes really are distinct in capability posture: the shared |
| 1846 | // hash is a genuine cross-route agreement, not five copies of one |
| 1847 | // route. GLM-5.2 publishes a `Full` tool surface budget while |
| 1848 | // GLM-5-Turbo publishes `Standard`, and the catalog identity is |
| 1849 | // unchanged by that difference. |
| 1850 | let budgets: std::collections::BTreeSet<&str> = observed |
| 1851 | .iter() |
| 1852 | .map(|(_, _, _, budget)| budget.as_str()) |
| 1853 | .collect(); |
| 1854 | assert!( |
| 1855 | budgets.len() > 1, |
| 1856 | "the matrix spans routes with different surface budgets: {budgets:?}" |
| 1857 | ); |
| 1858 | } |
| 1859 | |
| 1860 | /// Provider-authoritative usage is never a preview fact, and it is never a |
| 1861 | /// zero standing in for "not measured". It becomes knowable only when a |
| 1862 | /// response reports it, through the same `parse_usage` seam the turn loop |
| 1863 | /// uses. |
| 1864 | #[tokio::test] |
| 1865 | async fn provider_reported_usage_is_unavailable_until_a_response_reports_it() { |
| 1866 | use wiremock::matchers::method; |
| 1867 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 1868 | |
| 1869 | let usage = json!({"prompt_tokens": 137, "completion_tokens": 24, "total_tokens": 161}); |
| 1870 | let stream = format!( |
| 1871 | "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", |
| 1872 | json!({ |
| 1873 | "choices": [{"index": 0, "delta": {"content": "ok"}}], |
| 1874 | }), |
| 1875 | json!({ |
| 1876 | "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], |
| 1877 | "usage": usage, |
| 1878 | }) |
| 1879 | ); |
| 1880 | |
| 1881 | let server = MockServer::start().await; |
| 1882 | Mock::given(method("POST")) |
| 1883 | .respond_with( |
| 1884 | ResponseTemplate::new(200) |
| 1885 | .insert_header("content-type", "text/event-stream") |
| 1886 | .set_body_string(stream), |
| 1887 | ) |
| 1888 | .mount(&server) |
| 1889 | .await; |
| 1890 | |
| 1891 | let mut config = deepseek_config(); |
| 1892 | config |
| 1893 | .providers |
| 1894 | .as_mut() |
| 1895 | .expect("providers") |
| 1896 | .deepseek |
| 1897 | .base_url = Some(server.uri()); |
| 1898 | let identity = deepseek_identity(); |
| 1899 | let (mut engine, _tmp) = wire_preview_engine(&config); |
| 1900 | |
| 1901 | let prompt = "count the tokens this turn will report"; |
| 1902 | let planned = plan(&config, &identity, false, prompt).await; |
| 1903 | let production_route = planned.route.clone(); |
| 1904 | let compaction = planned.compaction.clone(); |
| 1905 | let reasoning_effort = planned.effective_reasoning_effort.clone(); |
| 1906 | let reasoning_effort_auto = planned.auto_controls_reasoning; |
| 1907 | |
| 1908 | let manifest = engine |
| 1909 | .build_request_manifest(inputs(false, Some(planned), prompt)) |
| 1910 | .await; |
| 1911 | let body = manifest.body.exact().expect("a prompted body is exact"); |
| 1912 | assert!( |
| 1913 | matches!( |
| 1914 | &body.provider_reported_usage, |
| 1915 | Availability::Unavailable(unavailable) |
| 1916 | if unavailable.reason == UnavailableReason::ProviderRequestNotExecuted |
| 1917 | ), |
| 1918 | "no request occurred, so there is nothing the provider reported" |
| 1919 | ); |
| 1920 | assert_eq!( |
| 1921 | engine.session.total_usage.input_tokens, 0, |
| 1922 | "and nothing has been recorded yet" |
| 1923 | ); |
| 1924 | assert_eq!(engine.session.total_usage.output_tokens, 0); |
| 1925 | |
| 1926 | let _ = engine |
| 1927 | .handle_send_message(TurnSpec { |
| 1928 | content: prompt.to_string(), |
| 1929 | mode: AppMode::Agent, |
| 1930 | route: Box::new(production_route), |
| 1931 | compaction: Box::new(compaction), |
| 1932 | initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()), |
| 1933 | goal_objective: None, |
| 1934 | goal_token_budget: None, |
| 1935 | goal_status: GoalStatus::Active, |
| 1936 | reasoning_effort, |
| 1937 | reasoning_effort_auto, |
| 1938 | auto_model: false, |
| 1939 | allow_shell: false, |
| 1940 | trust_mode: false, |
| 1941 | auto_approve: false, |
| 1942 | approval_mode: ApprovalMode::Suggest, |
| 1943 | translation_enabled: false, |
| 1944 | allowed_tools: None, |
| 1945 | dynamic_tools: Vec::new(), |
| 1946 | hook_executor: None, |
| 1947 | verbosity: None, |
| 1948 | provenance: UserInputProvenance::ExternalUser, |
| 1949 | images: Vec::new(), |
| 1950 | max_output_tokens: None, |
| 1951 | }) |
| 1952 | .await; |
| 1953 | |
| 1954 | // The completed turn's counts are exactly what `parse_usage` reads off |
| 1955 | // the reported usage object — no rounding, no substituted estimate. |
| 1956 | let parsed = crate::client::parse_usage(Some(&usage)); |
| 1957 | assert_eq!(u64::from(parsed.input_tokens), 137); |
| 1958 | assert_eq!(u64::from(parsed.output_tokens), 24); |
| 1959 | assert_eq!( |
| 1960 | ( |
| 1961 | engine.session.total_usage.input_tokens, |
| 1962 | engine.session.total_usage.output_tokens |
| 1963 | ), |
| 1964 | ( |
| 1965 | u64::from(parsed.input_tokens), |
| 1966 | u64::from(parsed.output_tokens) |
| 1967 | ), |
| 1968 | "the turn records the provider-authoritative counts, not an estimate" |
| 1969 | ); |
| 1970 | let reported = crate::request_manifest::ProviderReportedUsage { |
| 1971 | input_tokens: engine.session.total_usage.input_tokens, |
| 1972 | output_tokens: engine.session.total_usage.output_tokens, |
| 1973 | }; |
| 1974 | assert_eq!(reported.input_tokens, 137); |
| 1975 | assert_eq!(reported.output_tokens, 24); |
| 1976 | } |
| 1977 | |
| 1978 | /// A fixed route with a hypothetical prompt describes the next turn |
| 1979 | /// exactly: route, tools, and body are all published, and the prompt is |
| 1980 | /// part of the hashed body. |
| 1981 | #[tokio::test] |
| 1982 | async fn fixed_route_with_a_prompt_describes_the_exact_next_turn() { |
| 1983 | let mut config = deepseek_config(); |
| 1984 | config |
| 1985 | .providers |
| 1986 | .as_mut() |
| 1987 | .expect("providers") |
| 1988 | .deepseek |
| 1989 | .context_window = Some(123_456); |
| 1990 | let identity = deepseek_identity(); |
| 1991 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 1992 | engine.config.features.disable(Feature::Mcp); |
| 1993 | engine.active_route_limits = Some(codewhale_config::route::RouteLimits { |
| 1994 | context_tokens: Some(4_096), |
| 1995 | input_tokens: Some(3_000), |
| 1996 | output_tokens: Some(512), |
| 1997 | }); |
| 1998 | |
| 1999 | let planned = plan(&config, &identity, false, "refactor the parser").await; |
| 2000 | let planned_limits = crate::route_budget::known_route_limits(planned.route.candidate.limits()); |
| 2001 | let expected_input_budget = context_input_budget_for_route( |
| 2002 | planned.route.identity.provider, |
| 2003 | &planned.route.model, |
| 2004 | planned_limits, |
| 2005 | 0, |
| 2006 | ); |
| 2007 | let expected_wire_output = crate::route_budget::effective_max_output_tokens_for_route( |
| 2008 | planned.route.identity.provider, |
| 2009 | &planned.route.model, |
| 2010 | planned_limits, |
| 2011 | ); |
| 2012 | let manifest = engine |
| 2013 | .build_request_manifest(inputs(false, Some(planned), "refactor the parser")) |
| 2014 | .await; |
| 2015 | |
| 2016 | let route = manifest.route.exact().expect("a fixed route is exact"); |
| 2017 | assert_eq!(route.provider_id.as_str(), "deepseek"); |
| 2018 | assert_eq!(route.routing_source, "active-fixed-route"); |
| 2019 | assert_eq!(route.dialect, "chat-completions"); |
| 2020 | assert_eq!(route.caller_entrypoint, "streaming"); |
| 2021 | assert_eq!(route.body_stream_field, Some(true)); |
| 2022 | assert_eq!(route.context_limit_tokens, 123_456); |
| 2023 | assert_eq!( |
| 2024 | route.context_limit_source, |
| 2025 | crate::route_runtime::ContextWindowSource::Configured |
| 2026 | ); |
| 2027 | assert_eq!( |
| 2028 | route.route_input_limit_tokens, |
| 2029 | planned_limits.and_then(|limits| limits.input_tokens) |
| 2030 | ); |
| 2031 | assert_eq!( |
| 2032 | route.route_output_limit_tokens, |
| 2033 | planned_limits.and_then(|limits| limits.output_tokens) |
| 2034 | ); |
| 2035 | assert!(!route.wire_model.is_redacted()); |
| 2036 | assert!( |
| 2037 | manifest.tools.exact().is_some(), |
| 2038 | "MCP is off in this engine" |
| 2039 | ); |
| 2040 | |
| 2041 | let body = manifest.body.exact().expect("a prompted body is exact"); |
| 2042 | assert_eq!(body.input_budget_ceiling_tokens, expected_input_budget); |
| 2043 | assert_eq!( |
| 2044 | body.wire_output_cap_tokens, |
| 2045 | Some(u64::from(expected_wire_output)) |
| 2046 | ); |
| 2047 | assert_eq!(body.body_sha256.len(), 64); |
| 2048 | assert!( |
| 2049 | body.message_count >= 1, |
| 2050 | "the hypothetical prompt is a message" |
| 2051 | ); |
| 2052 | assert!(body.local_system_tools_component_sha256.is_some()); |
| 2053 | assert!(manifest.session.hypothetical_prompt_supplied); |
| 2054 | |
| 2055 | // The prompt is genuinely part of the request being described. |
| 2056 | let other_planned = plan(&config, &identity, false, "write the release notes").await; |
| 2057 | let other = engine |
| 2058 | .build_request_manifest(inputs( |
| 2059 | false, |
| 2060 | Some(other_planned), |
| 2061 | "write the release notes", |
| 2062 | )) |
| 2063 | .await; |
| 2064 | assert_ne!( |
| 2065 | body.body_sha256, |
| 2066 | other.body.exact().expect("exact").body_sha256, |
| 2067 | "a different next prompt must produce a different body hash" |
| 2068 | ); |
| 2069 | } |
| 2070 | |
| 2071 | /// The engine can describe an Auto route receipt supplied by a trusted |
| 2072 | /// host without consulting installed state. The human preview command |
| 2073 | /// deliberately never obtains such a receipt, because doing so would call |
| 2074 | /// the provider-backed classifier. |
| 2075 | #[tokio::test] |
| 2076 | async fn host_supplied_auto_route_receipt_matches_the_production_planner() { |
| 2077 | let config = deepseek_config(); |
| 2078 | let identity = deepseek_identity(); |
| 2079 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 2080 | engine.config.features.disable(Feature::Mcp); |
| 2081 | |
| 2082 | let planned = plan(&config, &identity, true, "explain this stack trace").await; |
| 2083 | let planned_provider = planned.effective_provider; |
| 2084 | let planned_identity = planned.effective_provider_identity.clone(); |
| 2085 | let planned_model = planned.route.model.clone(); |
| 2086 | let planned_base_url = planned.route.candidate.endpoint().base_url.clone(); |
| 2087 | assert!( |
| 2088 | planned.auto_controls_reasoning, |
| 2089 | "the helper requests auto reasoning for its auto-model fixture" |
| 2090 | ); |
| 2091 | |
| 2092 | let manifest = engine |
| 2093 | .build_request_manifest(inputs(true, Some(planned), "explain this stack trace")) |
| 2094 | .await; |
| 2095 | |
| 2096 | let route = manifest |
| 2097 | .route |
| 2098 | .exact() |
| 2099 | .expect("auto + prompt resolves a route"); |
| 2100 | assert_eq!(route.provider_id.as_str(), planned_provider.as_str()); |
| 2101 | assert_eq!(route.routing_source, "auto-provider-classifier"); |
| 2102 | assert_eq!(planned_identity, "deepseek"); |
| 2103 | assert_eq!( |
| 2104 | route.wire_model.as_str(), |
| 2105 | crate::config::wire_model_for_provider_route( |
| 2106 | planned_provider, |
| 2107 | &planned_base_url, |
| 2108 | &planned_model, |
| 2109 | ), |
| 2110 | "the wire model is the planner's model after route remapping — not \ |
| 2111 | the model the session happens to have installed" |
| 2112 | ); |
| 2113 | assert_eq!( |
| 2114 | manifest.session.requested_model.as_str(), |
| 2115 | "auto", |
| 2116 | "the manifest never reports the resolved model as the user's selection" |
| 2117 | ); |
| 2118 | |
| 2119 | let body = match &manifest.body { |
| 2120 | Availability::Exact(body) => body, |
| 2121 | Availability::Unavailable(unavailable) => { |
| 2122 | panic!("auto + prompt should have an exact body: {unavailable:?}") |
| 2123 | } |
| 2124 | }; |
| 2125 | assert_ne!( |
| 2126 | body.reasoning_resolution, |
| 2127 | ReasoningResolution::Explicit, |
| 2128 | "an auto-routed turn never claims an explicit user tier" |
| 2129 | ); |
| 2130 | |
| 2131 | // The hypothetical prompt is part of the hashed body on the auto path |
| 2132 | // too, not only on the fixed one. |
| 2133 | let other = plan(&config, &identity, true, "rename one local variable").await; |
| 2134 | let other = engine |
| 2135 | .build_request_manifest(inputs(true, Some(other), "rename one local variable")) |
| 2136 | .await; |
| 2137 | assert_ne!( |
| 2138 | body.body_sha256, |
| 2139 | other.body.exact().expect("exact").body_sha256 |
| 2140 | ); |
| 2141 | } |
| 2142 | |
| 2143 | /// The passive path must not create an MCP pool, connect a server, or |
| 2144 | /// emit a UI event — it reports the tool surface unavailable instead. |
| 2145 | #[tokio::test] |
| 2146 | async fn preview_tool_snapshot_has_no_mcp_or_event_side_effects() { |
| 2147 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 2148 | let config = crate::config::Config { |
| 2149 | provider: Some("deepseek".to_string()), |
| 2150 | ..crate::config::Config::default() |
| 2151 | }; |
| 2152 | let (mut engine, handle) = Engine::new( |
| 2153 | EngineConfig { |
| 2154 | workspace: tmp.path().to_path_buf(), |
| 2155 | ..Default::default() |
| 2156 | }, |
| 2157 | &config, |
| 2158 | ); |
| 2159 | let _ = engine.config.features.enable(Feature::Mcp); |
| 2160 | |
| 2161 | let policy = TurnAuthority::from_effective_fields( |
| 2162 | AppMode::Agent, |
| 2163 | false, |
| 2164 | false, |
| 2165 | false, |
| 2166 | ApprovalMode::Suggest, |
| 2167 | ); |
| 2168 | let build = engine |
| 2169 | .build_turn_tool_registry_and_catalog( |
| 2170 | &policy, |
| 2171 | &[], |
| 2172 | None, |
| 2173 | SubAgentWiring::Inert, |
| 2174 | McpAccess::PassiveSnapshot, |
| 2175 | TurnRouteContext { |
| 2176 | provider: engine.api_provider, |
| 2177 | model: engine.session.model.clone(), |
| 2178 | capabilities: engine.active_route_capabilities, |
| 2179 | limits: engine.active_route_limits, |
| 2180 | client: engine.codewhale_client.clone(), |
| 2181 | api_config: Box::new(engine.api_config.clone()), |
| 2182 | locale_tag: engine.config.locale_tag.clone(), |
| 2183 | role_models: engine.subagent_role_models(), |
| 2184 | auto_model: false, |
| 2185 | reasoning_effort: None, |
| 2186 | reasoning_effort_auto: false, |
| 2187 | }, |
| 2188 | "", |
| 2189 | ) |
| 2190 | .await; |
| 2191 | |
| 2192 | assert!( |
| 2193 | engine.mcp_pool.is_none(), |
| 2194 | "a passive snapshot must not create the MCP pool" |
| 2195 | ); |
| 2196 | assert!( |
| 2197 | matches!(build.mcp, McpToolState::Unavailable { .. }), |
| 2198 | "with no connected pool the MCP tool state is unavailable, not empty" |
| 2199 | ); |
| 2200 | assert!(build.mcp.server_count().is_none()); |
| 2201 | drop(handle); |
| 2202 | } |
| 2203 | |
| 2204 | /// The reviewed blocker: with MCP enabled but nothing connected, the |
| 2205 | /// preview built a catalog with zero MCP tools, prepared a body from it, |
| 2206 | /// and published that body as `Exact` — a hash of a request no turn would |
| 2207 | /// ever send. The body must inherit the tool surface's typed reason. |
| 2208 | #[tokio::test] |
| 2209 | async fn unavailable_mcp_state_makes_the_body_unavailable_too() { |
| 2210 | let config = deepseek_config(); |
| 2211 | let identity = deepseek_identity(); |
| 2212 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 2213 | // MCP on, pool never started: a real turn would connect and could |
| 2214 | // discover tools this catalog does not contain. |
| 2215 | let _ = engine.config.features.enable(Feature::Mcp); |
| 2216 | |
| 2217 | let planned = plan(&config, &identity, false, "refactor the parser").await; |
| 2218 | let manifest = engine |
| 2219 | .build_request_manifest(inputs(false, Some(planned), "refactor the parser")) |
| 2220 | .await; |
| 2221 | |
| 2222 | assert!( |
| 2223 | manifest.tools.exact().is_none(), |
| 2224 | "an unconnected MCP pool is not a snapshottable tool surface" |
| 2225 | ); |
| 2226 | assert!( |
| 2227 | manifest.body.exact().is_none(), |
| 2228 | "a body built from a tool surface missing its MCP contribution \ |
| 2229 | must not be published as exact" |
| 2230 | ); |
| 2231 | assert!( |
| 2232 | manifest.route.exact().is_some(), |
| 2233 | "the route does not depend on the MCP contribution and stays exact" |
| 2234 | ); |
| 2235 | |
| 2236 | // No body fact — hash, byte count, or local component fingerprint — |
| 2237 | // reaches either surface. |
| 2238 | let json = manifest.to_json(); |
| 2239 | for forbidden in [ |
| 2240 | "body_sha256", |
| 2241 | "local_system_tools_component_sha256", |
| 2242 | "tool_schema_wire_sha256", |
| 2243 | "body_canonical_json_bytes", |
| 2244 | "estimated_input_headroom_tokens", |
| 2245 | ] { |
| 2246 | assert!(!json.contains(forbidden), "{forbidden} leaked:\n{json}"); |
| 2247 | } |
| 2248 | assert!(json.contains("mcp-state-not-snapshottable"), "{json}"); |
| 2249 | assert!(engine.mcp_pool.is_none(), "no pool was created by looking"); |
| 2250 | } |
| 2251 | |
| 2252 | /// A preview is an inspection. Every piece of engine state a turn would |
| 2253 | /// have written must be byte-identical afterwards — including the ones the |
| 2254 | /// earlier implementation wrote and restored around an `.await`. |
| 2255 | #[tokio::test] |
| 2256 | async fn building_a_manifest_writes_no_engine_state() { |
| 2257 | let config = deepseek_config(); |
| 2258 | let identity = deepseek_identity(); |
| 2259 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 2260 | engine.config.features.disable(Feature::Mcp); |
| 2261 | engine.config.allowed_tools = Some(vec!["Bash".to_string()]); |
| 2262 | engine.session.add_message(Message { |
| 2263 | role: Role::User, |
| 2264 | content: vec![ContentBlock::Text { |
| 2265 | text: "an earlier turn".to_string(), |
| 2266 | cache_control: None, |
| 2267 | }], |
| 2268 | }); |
| 2269 | |
| 2270 | let allowed_before = engine.config.allowed_tools.clone(); |
| 2271 | let disallowed_before = engine.config.disallowed_tools.clone(); |
| 2272 | let messages_before = engine.messages_with_turn_metadata(); |
| 2273 | let model_before = engine.session.model.clone(); |
| 2274 | let system_prompt_before = system_prompt_hash(engine.session.system_prompt.as_ref()); |
| 2275 | let system_hash_before = engine.session.last_system_prompt_hash; |
| 2276 | let working_set_before = engine |
| 2277 | .session |
| 2278 | .working_set |
| 2279 | .summary_block(&engine.config.workspace); |
| 2280 | let provider_before = engine.api_provider; |
| 2281 | let mode_before = engine.current_mode; |
| 2282 | let narrowing_before = format!("{:?}", engine.last_policy_narrowing); |
| 2283 | let turn_counter_before = engine.turn_counter; |
| 2284 | |
| 2285 | // A *different* command-scoped gate than the installed one, and a |
| 2286 | // prompt that mentions a path so the working set would move if the |
| 2287 | // preview observed it on the session rather than on a clone. |
| 2288 | let mut preview_inputs = inputs( |
| 2289 | false, |
| 2290 | Some(plan(&config, &identity, false, "inspect src/lib.rs").await), |
| 2291 | "inspect src/lib.rs", |
| 2292 | ); |
| 2293 | preview_inputs.allowed_tools = Some(vec!["Read".to_string()]); |
| 2294 | let manifest = engine.build_request_manifest(preview_inputs).await; |
| 2295 | assert!(manifest.body.exact().is_some(), "fixture should be exact"); |
| 2296 | |
| 2297 | assert_eq!(engine.config.allowed_tools, allowed_before, "tool gate"); |
| 2298 | assert_eq!(engine.config.disallowed_tools, disallowed_before); |
| 2299 | assert_eq!( |
| 2300 | engine.messages_with_turn_metadata(), |
| 2301 | messages_before, |
| 2302 | "history" |
| 2303 | ); |
| 2304 | assert_eq!(engine.session.model, model_before); |
| 2305 | assert_eq!( |
| 2306 | system_prompt_hash(engine.session.system_prompt.as_ref()), |
| 2307 | system_prompt_before |
| 2308 | ); |
| 2309 | assert_eq!(engine.session.last_system_prompt_hash, system_hash_before); |
| 2310 | assert_eq!( |
| 2311 | engine |
| 2312 | .session |
| 2313 | .working_set |
| 2314 | .summary_block(&engine.config.workspace), |
| 2315 | working_set_before, |
| 2316 | "the hypothetical message is observed on a clone, never on the session" |
| 2317 | ); |
| 2318 | assert_eq!(engine.api_provider, provider_before); |
| 2319 | assert_eq!(engine.current_mode, mode_before); |
| 2320 | assert_eq!( |
| 2321 | format!("{:?}", engine.last_policy_narrowing), |
| 2322 | narrowing_before |
| 2323 | ); |
| 2324 | assert_eq!(engine.turn_counter, turn_counter_before); |
| 2325 | assert!(engine.mcp_pool.is_none()); |
| 2326 | } |
| 2327 | |
| 2328 | /// The gate is a parameter, so it shapes the previewed catalog without |
| 2329 | /// ever being installed. |
| 2330 | #[tokio::test] |
| 2331 | async fn the_previewed_tool_gate_applies_without_being_installed() { |
| 2332 | let config = deepseek_config(); |
| 2333 | let identity = deepseek_identity(); |
| 2334 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 2335 | engine.config.features.disable(Feature::Mcp); |
| 2336 | |
| 2337 | let wide = engine |
| 2338 | .build_request_manifest(inputs( |
| 2339 | false, |
| 2340 | Some(plan(&config, &identity, false, "do the thing").await), |
| 2341 | "do the thing", |
| 2342 | )) |
| 2343 | .await; |
| 2344 | |
| 2345 | let mut narrow_inputs = inputs( |
| 2346 | false, |
| 2347 | Some(plan(&config, &identity, false, "do the thing").await), |
| 2348 | "do the thing", |
| 2349 | ); |
| 2350 | narrow_inputs.allowed_tools = Some(vec!["Read".to_string()]); |
| 2351 | let narrow = engine.build_request_manifest(narrow_inputs).await; |
| 2352 | |
| 2353 | let wide_tools = wide.tools.exact().expect("exact"); |
| 2354 | let narrow_tools = narrow.tools.exact().expect("exact"); |
| 2355 | assert!( |
| 2356 | narrow_tools.active_tool_count < wide_tools.active_tool_count, |
| 2357 | "the passed gate must narrow the previewed catalog: {} vs {}", |
| 2358 | narrow_tools.active_tool_count, |
| 2359 | wide_tools.active_tool_count |
| 2360 | ); |
| 2361 | assert_eq!( |
| 2362 | narrow.session.allowed_tool_gate_count, |
| 2363 | Some(1), |
| 2364 | "and the session section reports the gate that was previewed" |
| 2365 | ); |
| 2366 | assert_eq!( |
| 2367 | engine.config.allowed_tools, None, |
| 2368 | "…while the engine keeps its own" |
| 2369 | ); |
| 2370 | } |
| 2371 | |
| 2372 | /// A plan failure still happened *because of* a supplied prompt. Reporting |
| 2373 | /// otherwise tells the user to pass the flag they just passed. |
| 2374 | #[tokio::test] |
| 2375 | async fn a_failed_plan_still_reports_that_a_prompt_was_supplied() { |
| 2376 | let (mut engine, _handle, _tmp) = preview_engine(&crate::config::Config::default()); |
| 2377 | let mut failed = inputs(false, None, ""); |
| 2378 | failed.unresolved = PreviewUnresolved::PlanFailed( |
| 2379 | "no API key configured for route 'my-gateway' at /home/someone/.config".to_string(), |
| 2380 | ); |
| 2381 | |
| 2382 | let manifest = engine.build_request_manifest(failed).await; |
| 2383 | assert!(manifest.session.hypothetical_prompt_supplied); |
| 2384 | assert!(manifest.route.exact().is_none()); |
| 2385 | |
| 2386 | let rendered = manifest.render(); |
| 2387 | assert!( |
| 2388 | !rendered.contains("Pass `--prompt <text>`"), |
| 2389 | "the user already did:\n{rendered}" |
| 2390 | ); |
| 2391 | // …and the raw host text never reaches a surface verbatim. |
| 2392 | for surface in [rendered, manifest.to_json()] { |
| 2393 | assert!(!surface.contains("my-gateway'"), "{surface}"); |
| 2394 | assert!(!surface.contains("/home/someone"), "{surface}"); |
| 2395 | } |
| 2396 | } |
| 2397 | |
| 2398 | /// Pending runtime injections are *counted*, never consumed, and they make |
| 2399 | /// the body unavailable rather than silently absent from it. |
| 2400 | #[tokio::test] |
| 2401 | async fn pending_runtime_injections_make_the_body_unavailable_without_consuming_them() { |
| 2402 | let config = deepseek_config(); |
| 2403 | let identity = deepseek_identity(); |
| 2404 | let (mut engine, _handle, _tmp) = preview_engine(&config); |
| 2405 | engine.config.features.disable(Feature::Mcp); |
| 2406 | engine.pending_lsp_blocks.push(crate::lsp::DiagnosticBlock { |
| 2407 | file: std::path::PathBuf::from("src/lib.rs"), |
| 2408 | items: Vec::new(), |
| 2409 | }); |
| 2410 | |
| 2411 | let manifest = engine |
| 2412 | .build_request_manifest(inputs( |
| 2413 | false, |
| 2414 | Some(plan(&config, &identity, false, "fix it").await), |
| 2415 | "fix it", |
| 2416 | )) |
| 2417 | .await; |
| 2418 | |
| 2419 | assert!( |
| 2420 | manifest.body.exact().is_none(), |
| 2421 | "the turn loop would inject diagnostics before the first request" |
| 2422 | ); |
| 2423 | assert!(manifest.route.exact().is_some()); |
| 2424 | assert_eq!( |
| 2425 | engine.pending_lsp_blocks.len(), |
| 2426 | 1, |
| 2427 | "inspecting must not flush the pending blocks" |
| 2428 | ); |
| 2429 | assert!( |
| 2430 | manifest |
| 2431 | .to_json() |
| 2432 | .contains("runtime-transforms-before-send"), |
| 2433 | "{}", |
| 2434 | manifest.to_json() |
| 2435 | ); |
| 2436 | } |
| 2437 |