返回 CodeWhale
tests.rs
根目录 / crates / tui / src / core / engine / preview / tests.rs
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
2437 lines RUST