返回 CodeWhale
preview.rs
根目录 / crates / tui / src / core / engine / preview.rs
1 //! Engine-side authority for `/preview-request` (#1004, #3928).
2 //!
3 //! The preview lives here — not in the command layer — because only the
4 //! engine can rebuild the *exact* next-turn state: the tool catalog under the
5 //! live mode, gates, permission posture and connected MCP tools; the system
6 //! prompt for the route the next turn would use; the hypothetical next user
7 //! message in its production form; and the request the turn loop would hand
8 //! to `create_message_stream`.
9 //!
10 //! Four rules this module exists to enforce:
11 //!
12 //! - **Never `session.last_tool_catalog`.** That value is one turn stale and
13 //! stores the pre-activation catalog, so it cannot describe what the *next*
14 //! request would send. The catalog is rebuilt through
15 //! [`Engine::build_turn_tool_registry_and_catalog`], which returns the same
16 //! typed policy a real turn consumes.
17 //! - **Never invent a route.** For fixed routes, the host resolves the next
18 //! turn through the same shared planner production dispatch uses. Auto would
19 //! require a model-classifier call, so the human preview stops before the
20 //! planner and emits a typed unavailable state. No route, endpoint, wire
21 //! model, billing, tool budget, or body hash is recycled from the installed
22 //! route.
23 //! - **Never resolve by side effect.** The catalog build runs with
24 //! [`SubAgentWiring::Inert`] and [`McpAccess::PassiveSnapshot`]: no fork
25 //! snapshot, no spawned drainer, no MCP pool creation, no `connect_all`, no
26 //! status events. When the connected MCP state is not already exactly what
27 //! a turn would use, the tool section is reported unavailable rather than
28 //! made exact by connecting — **and so is the body**, because a body built
29 //! from a tool surface missing its MCP contribution is a body no turn would
30 //! send.
31 //! - **Never install anything, not even briefly.** The planned route is
32 //! projected into a throw-away client; `self.api_provider`,
33 //! `self.session.model`, `self.session.system_prompt`, and the MCP pool are
34 //! all left untouched. Everything a turn would *install before* building its
35 //! request — the command-scoped tool gate, the effective mode and approval
36 //! posture, the policy-narrowing event, the observed working set — is passed
37 //! as a value or snapshotted onto a clone. There is no write-then-restore
38 //! anywhere in this module: a restore is not atomic across an `.await`, and
39 //! it does not survive a cancellation or a panic.
40 //! - **Never claim exactness the runtime would break.** Mutable
41 //! `message_submit` hooks, background-shell completions, running or
42 //! terminal-undelivered sub-agent completions,
43 //! pending LSP diagnostics, auto-compaction, and context-overflow recovery
44 //! all rewrite the request between submit and the wire. An inspection may
45 //! neither run them nor consume them, so when any of them apply the affected
46 //! sections are typed unavailable instead of published.
47 //!
48 //! Scope: this describes the primary agent turn (`create_message_stream`).
49 //! Auxiliary provider calls are out of scope; see `docs/PREVIEW_REQUEST.md`.
50 //!
51 //! The `dryrun` concept — preview the next request from the real
52 //! request-building seam rather than a hand-rolled summary — is harvested
53 //! from PR #1099 by TaoMu (GTC2080).
54
55 use super::*;
56
57 use crate::client::PreparedOutboundRequest;
58 use crate::request_manifest::{
59 Availability, BasePromptProvenance, BillingFacts, ManifestDraft, PreparedBodyInputs,
60 PromptProvenance, ReasoningResolution, RequestManifest, RouteFacts, SessionFacts,
61 SystemPromptAssembly, ToolSurfaceFacts, UnavailableReason,
62 };
63 use crate::route_runtime::ResolvedRuntimeRoute;
64 use crate::safe_label::SafeLabel;
65
66 /// Everything the host must supply for the engine to describe the next
67 /// request. These are the same posture fields a `SendMessage` would carry, so
68 /// the preview describes the turn the user is actually about to run.
69 #[derive(Debug)]
70 pub struct PreviewRequestInputs {
71 pub mode: AppMode,
72 pub allow_shell: bool,
73 pub trust_mode: bool,
74 pub auto_approve: bool,
75 pub approval_mode: crate::tui::approval::ApprovalMode,
76 pub allowed_tools: Option<Vec<String>>,
77 pub dynamic_tools: Vec<DynamicToolSpec>,
78 pub provenance: UserInputProvenance,
79 /// The model selector the user chose: `auto` when auto model routing is
80 /// on. Never the concrete model an unresolved auto route might pick.
81 pub requested_model: String,
82 /// Reasoning tier the user has selected (`auto`, `high`, `off`, …).
83 pub requested_reasoning: String,
84 pub auto_model: bool,
85 /// Whether the *caller* supplied a hypothetical next prompt.
86 ///
87 /// Deliberately independent of [`Self::next_turn`]: when planning that
88 /// prompt fails, the manifest must still say a prompt was supplied.
89 /// Deriving the flag from `next_turn.is_some()` told the user to "pass
90 /// `--prompt`" when they just had.
91 pub hypothetical_prompt_supplied: bool,
92 /// The exact next turn, resolved by the host's shared route planner.
93 /// `None` means no exact next turn exists to describe.
94 pub next_turn: Option<Box<PreviewNextTurn>>,
95 /// Why `next_turn` is absent. Ignored when `next_turn` is present.
96 pub unresolved: PreviewUnresolved,
97 }
98
99 /// One hypothetical next turn, planned by the production route planner.
100 #[derive(Debug)]
101 pub struct PreviewNextTurn {
102 /// The model-facing text of the hypothetical user message, already
103 /// through the host's file-mention/skill resolution — the same string a
104 /// real `SendMessage` would carry. Never stored in the session and never
105 /// sent to a provider.
106 pub content: String,
107 /// The route the planner resolved for this turn.
108 pub route: Box<ResolvedRuntimeRoute>,
109 /// Immutable prompt facts captured from the same host state as the
110 /// matching production submit.
111 pub prompt_context: NextTurnPromptContext,
112 /// Normalized reasoning-effort api value from the planner, exactly as it
113 /// would be sent.
114 pub reasoning_effort: Option<String>,
115 /// True when the user selected auto reasoning and the planner picked that
116 /// tier.
117 pub reasoning_effort_auto: bool,
118 /// How the auto router chose this route, when auto routing ran.
119 pub auto_route_source: Option<String>,
120 /// Typed selection provenance captured by the shared production planner.
121 pub routing_source: crate::turn_route_plan::TurnRoutingSource,
122 /// The compaction policy the planner resolved for this route. A real turn
123 /// installs it before the turn loop decides whether to auto-compact, so
124 /// the preview evaluates that decision against the same policy.
125 pub compaction: crate::compaction::CompactionConfig,
126 }
127
128 /// Why no exact next turn was planned.
129 #[derive(Debug, Clone)]
130 pub enum PreviewUnresolved {
131 /// Auto model routing is on and no hypothetical prompt was supplied.
132 AutoRouteNeedsPrompt,
133 /// Auto model routing needs a classifier provider call. A preview is
134 /// strictly offline, so it stops before invoking the shared route planner.
135 AutoRouteClassificationNotExecuted,
136 /// No hypothetical prompt was supplied, so there is no next-turn body.
137 NoPrompt,
138 /// The shared planner ran and failed. Carries raw host text; it crosses
139 /// the safe-label boundary before it reaches any surface.
140 PlanFailed(String),
141 /// Mutable `message_submit` hooks are configured. A real submit runs them
142 /// before file mentions, skill wrapping, route planning, and the tool
143 /// policy see the text, and they may replace or block it outright. An
144 /// inspection must not execute a hook, so nothing downstream of the text
145 /// — route, tools, or body — can be claimed exact.
146 MessageSubmitHooksConfigured,
147 /// Resolving the prompt into model-facing content failed exactly as a real
148 /// submit would have failed. Carries raw host text.
149 PromptResolutionFailed(String),
150 }
151
152 impl PreviewUnresolved {
153 fn as_availability<T>(&self) -> Availability<T> {
154 match self {
155 Self::AutoRouteNeedsPrompt => {
156 Availability::unavailable(UnavailableReason::AutoRouteUnresolvedUntilNextPrompt)
157 }
158 Self::AutoRouteClassificationNotExecuted => {
159 Availability::unavailable(UnavailableReason::AutoRouteClassificationNotExecuted)
160 }
161 Self::NoPrompt => {
162 Availability::unavailable(UnavailableReason::NoHypotheticalPromptSupplied)
163 }
164 Self::PlanFailed(error) => {
165 Availability::unavailable_with(UnavailableReason::RoutePlanFailed, error.clone())
166 }
167 Self::MessageSubmitHooksConfigured => {
168 Availability::unavailable(UnavailableReason::MessageSubmitHooksNotExecuted)
169 }
170 Self::PromptResolutionFailed(error) => Availability::unavailable_with(
171 UnavailableReason::PromptResolutionFailed,
172 error.clone(),
173 ),
174 }
175 }
176 }
177
178 impl Engine {
179 /// Describe the request the next turn would send, without sending it.
180 pub(super) async fn build_request_manifest(
181 &mut self,
182 inputs: PreviewRequestInputs,
183 ) -> RequestManifest {
184 let session = self.preview_session_facts(&inputs);
185
186 // Mirror terminal continuation gates before request construction.
187 // Token budgets are telemetry-only in unbounded goal mode, so an
188 // active goal remains previewable after crossing or lowering a budget.
189 let goal_budget_exhausted = match self.config.goal_state.lock() {
190 Ok(state) => {
191 let snapshot = state.snapshot();
192 Ok(snapshot.is_active()
193 && crate::goal_loop::token_budget_exhausted(
194 crate::goal_loop::GoalProgress {
195 tokens_used: snapshot.tokens_used,
196 time_used_seconds: snapshot.time_used_seconds,
197 continuations: snapshot.continuation_count,
198 },
199 crate::goal_loop::GoalBudget {
200 token_budget: snapshot.token_budget.map(u64::from),
201 time_budget_seconds: None,
202 max_continuations: self.config.goal_max_continuations,
203 },
204 ))
205 }
206 Err(err) => {
207 tracing::warn!("goal state lock poisoned while previewing request: {err}");
208 Err(())
209 }
210 };
211 let unavailable_reason = match goal_budget_exhausted {
212 Ok(true) => Some(UnavailableReason::GoalTokenBudgetExhausted),
213 Ok(false) => None,
214 Err(()) => Some(UnavailableReason::GoalStateNotSnapshottable),
215 };
216 if let Some(reason) = unavailable_reason {
217 return RequestManifest::build(ManifestDraft {
218 session,
219 route: Availability::unavailable(reason),
220 tools: Availability::unavailable(reason),
221 body: Availability::unavailable(reason),
222 });
223 }
224
225 let Some(next_turn) = inputs.next_turn else {
226 let unresolved = inputs.unresolved;
227 return RequestManifest::build(ManifestDraft {
228 session,
229 route: unresolved.as_availability(),
230 tools: unresolved.as_availability(),
231 body: unresolved.as_availability(),
232 });
233 };
234
235 let PreviewNextTurn {
236 content: hypothetical_content,
237 route: planned_route,
238 prompt_context: planned_prompt_context,
239 reasoning_effort,
240 reasoning_effort_auto,
241 auto_route_source,
242 routing_source,
243 compaction: planned_compaction,
244 } = *next_turn;
245
246 // Project the planned route into a throw-away client. `validate`
247 // reuses the host's preflighted client when there is one and never
248 // touches engine state — unlike `install_resolved_runtime_route`,
249 // which is what a real turn calls.
250 let route = match (*planned_route).validate() {
251 Ok(route) => route,
252 Err(error) => {
253 let unavailable = PreviewUnresolved::PlanFailed(error);
254 return RequestManifest::build(ManifestDraft {
255 session,
256 route: unavailable.as_availability(),
257 tools: unavailable.as_availability(),
258 body: unavailable.as_availability(),
259 });
260 }
261 };
262
263 let provider = route.identity.provider;
264 let model = route.model.clone();
265 let limits = crate::route_budget::known_route_limits(route.candidate.limits());
266 let base_url = route.candidate.endpoint().base_url.clone();
267 let route_context = TurnRouteContext {
268 provider,
269 model: model.clone(),
270 capabilities: route.candidate.capabilities(),
271 limits,
272 client: Some(route.client.clone()),
273 api_config: route.config.clone(),
274 locale_tag: self.config.locale_tag.clone(),
275 role_models: self.subagent_role_models(),
276 fleet_roster: self.config.fleet_roster.clone(),
277 auto_model: inputs.auto_model,
278 reasoning_effort: reasoning_effort.clone(),
279 reasoning_effort_auto,
280 };
281
282 // Same policy derivation as `handle_send_message`, so the catalog is
283 // filtered under the posture the next turn would actually use.
284 let input_policy = effective_input_policy(
285 inputs.provenance,
286 inputs.mode,
287 &hypothetical_content,
288 inputs.allow_shell,
289 inputs.trust_mode,
290 inputs.mode == AppMode::Yolo || inputs.auto_approve,
291 inputs.approval_mode,
292 );
293 let prompt_context = NextTurnPromptContext {
294 mode: input_policy.mode,
295 ..planned_prompt_context
296 };
297
298 // The command-scoped allow gate is *passed*, never installed. The
299 // earlier shape wrote `self.config.allowed_tools`, awaited the whole
300 // catalog build, and wrote it back: for the duration of that await the
301 // engine carried a gate belonging to a turn that was never going to
302 // run, and a cancellation or panic in between would have left it
303 // installed for good.
304 let build = self
305 .build_turn_tool_registry_and_catalog(
306 &input_policy,
307 &inputs.dynamic_tools,
308 inputs.allowed_tools.clone(),
309 SubAgentWiring::Inert,
310 McpAccess::PassiveSnapshot,
311 route_context.clone(),
312 "",
313 )
314 .await;
315
316 // The build owns the exact same initial subset dispatch consumes.
317 let surface = &build.surface;
318 let active_tools = surface.active.clone().unwrap_or_default();
319 let active_catalog_sha256 = active_tool_catalog_sha256(&active_tools);
320
321 let tool_choice = surface.active.as_ref().map(|_| {
322 if surface.strict_tool_mode {
323 json!("required")
324 } else {
325 json!({ "type": "auto" })
326 }
327 });
328
329 // The tool surface is only publishable when the MCP contribution is
330 // exactly known. Anything else would be "the tools of some other
331 // turn", which is the failure mode this command exists to avoid.
332 let tools = match build.mcp.server_count() {
333 Some(mcp_server_count) => Availability::Exact(ToolSurfaceFacts {
334 catalog_tool_count: surface.catalog.len(),
335 deferred_tool_count: surface
336 .catalog
337 .iter()
338 .filter(|tool| tool.defer_loading.unwrap_or(false))
339 .count(),
340 active_tool_count: active_tools.len(),
341 active_tool_catalog_sha256: active_catalog_sha256,
342 tool_surface_budget: format!(
343 "{:?}",
344 route_context.capability_profile().tool_surface_budget
345 ),
346 standard_and_full_surfaces_collapsed: standard_and_full_collapse(
347 &surface.catalog,
348 &self.config.tools_always_load,
349 ),
350 mcp_server_count,
351 mcp_tool_count: active_tools
352 .iter()
353 .filter(|tool| build.mcp_tool_names.contains(&tool.name))
354 .count(),
355 }),
356 None => match &build.mcp {
357 McpToolState::Unavailable { reason } => Availability::unavailable_with(
358 UnavailableReason::McpStateNotSnapshottable,
359 reason.label(),
360 ),
361 McpToolState::Disabled | McpToolState::Live { .. } => {
362 Availability::unavailable(UnavailableReason::McpStateNotSnapshottable)
363 }
364 },
365 };
366
367 // The system prompt a turn would send is composed for *its* route, so
368 // an auto-routed preview must not reuse the installed model's prompt.
369 // A session-level override wins here exactly as it does in
370 // `refresh_system_prompt`.
371 let system_prompt = if self.session.system_prompt_override {
372 self.session.system_prompt.clone()
373 } else {
374 self.compose_stable_system_prompt(&prompt_context)
375 };
376
377 // The hypothetical user message goes through the same constructor
378 // production uses — turn metadata, route stamp, and provenance — so
379 // the body being hashed is the body a real turn would build. It is
380 // appended to a *clone* of the history and discarded: the session
381 // never sees it.
382 //
383 // A real submit calls `working_set.observe_user_message` before it
384 // writes `<turn_meta>`, so the block reflects files the new message
385 // mentions. The preview observes the message on a **clone** of the
386 // working set and builds the block from that snapshot: same bytes, no
387 // session write. Nothing here restores state, because nothing here
388 // changes any.
389 let mut previewed_working_set = self.session.working_set.clone();
390 previewed_working_set.observe_user_message(&hypothetical_content, &self.session.workspace);
391 // #5187: the git-snapshot line is emitted on change only, tracked in a
392 // session cache. Previewing a turn must not advance that cache — the
393 // model never saw the previewed block — so the cache is saved and
394 // restored around the hypothetical build, same as the working set.
395 let previewed_git_snapshot = self
396 .last_turn_meta_git_snapshot
397 .lock()
398 .unwrap_or_else(std::sync::PoisonError::into_inner)
399 .clone();
400 let hypothetical_user_message = self.user_text_message_from_snapshot(
401 hypothetical_content.clone(),
402 &model,
403 inputs.auto_model,
404 reasoning_effort.as_deref(),
405 reasoning_effort_auto,
406 inputs.provenance,
407 TurnMetadataSnapshot {
408 prompt_context: &prompt_context,
409 system_prompt: system_prompt.as_ref(),
410 approval_mode: input_policy.approval_mode_for_session(),
411 working_set: &previewed_working_set,
412 policy_narrowing: input_policy.narrowing.as_ref(),
413 },
414 );
415 *self
416 .last_turn_meta_git_snapshot
417 .lock()
418 .unwrap_or_else(std::sync::PoisonError::into_inner) = previewed_git_snapshot;
419 // Classification input for the provenance section: the prompt this
420 // request actually carries, not the session's current one.
421 let system_prompt_text = crate::prefix_cache::system_prompt_text(system_prompt.as_ref());
422
423 let mut messages = self.messages_with_turn_metadata();
424 messages.push(hypothetical_user_message);
425
426 // Transforms the turn loop would apply to this conversation between
427 // dispatch and the wire. Detected read-only; nothing pending is
428 // consumed, drained, or flushed by looking.
429 let mut runtime_transforms = self
430 .preview_runtime_transforms(&messages, &previewed_working_set, &planned_compaction)
431 .await;
432
433 // Resolve the same authoritative transient Work/To-do tail that the
434 // production loop snapshots once per request. It is appended to the
435 // outbound copy only: like production, auto-compaction and automatic
436 // reasoning inspect stored history plus the submitted user message,
437 // while preflight estimation and the provider body both receive this
438 // exact same tail value. A graph read failure cannot fall back to a
439 // stale legacy list in an exact preview.
440 let work_state_tail = match self.work_state_source().exact_tail_message().await {
441 Ok(tail) => tail,
442 Err(error) => {
443 return RequestManifest::build(ManifestDraft {
444 session,
445 route: Availability::unavailable_with(
446 UnavailableReason::WorkStateNotSnapshottable,
447 error.clone(),
448 ),
449 tools: Availability::unavailable_with(
450 UnavailableReason::WorkStateNotSnapshottable,
451 error.clone(),
452 ),
453 body: Availability::unavailable_with(
454 UnavailableReason::WorkStateNotSnapshottable,
455 error,
456 ),
457 });
458 }
459 };
460
461 // The turn loop resolves an `auto` sentinel tier against the messages
462 // it is about to send, *after* the planner normalized it. Skipping
463 // that step described a request carrying a literal `auto`, which no
464 // route receives.
465 let effective_reasoning_effort = super::turn_loop::resolve_auto_effort(
466 reasoning_effort.as_deref(),
467 &messages,
468 provider,
469 &base_url,
470 &model,
471 );
472
473 let mut outbound_messages = messages.clone();
474 if let Some(work_state_tail) = work_state_tail.as_ref() {
475 outbound_messages.push(work_state_tail.clone());
476 }
477
478 // The production overflow gate estimates the logical messages and
479 // system prompt, not serialized provider-body bytes. Use that same
480 // contract here; the manifest keeps its wire estimate separately as
481 // an observability metric.
482 let base_input_estimate_tokens = crate::compaction::estimate_input_tokens_conservative(
483 &messages,
484 system_prompt.as_ref(),
485 );
486 let production_input_estimate_tokens =
487 super::turn_loop::production_input_estimate_with_work_tail(
488 base_input_estimate_tokens,
489 work_state_tail.as_ref(),
490 );
491
492 let request = MessageRequest {
493 model: model.clone(),
494 messages: outbound_messages,
495 max_tokens: effective_max_output_tokens_for_route(provider, &model, limits),
496 system: system_prompt,
497 tools: surface.active.clone(),
498 tool_choice: tool_choice.clone(),
499 metadata: None,
500 thinking: None,
501 reasoning_effort: effective_reasoning_effort,
502 stream: Some(true),
503 temperature: None,
504 top_p: None,
505 };
506
507 let prepared = match route.client.prepare_outbound_request(request, true) {
508 Ok(prepared) => prepared.with_route_id(route.identity.exact_id.clone()),
509 Err(error) => {
510 // Route identity is read *off the prepared request*, so a
511 // preparation failure leaves the endpoint, wire model, and
512 // dialect unknown too. The tool surface survives: it was built
513 // before the body and does not depend on it.
514 return RequestManifest::build(ManifestDraft {
515 session,
516 route: Availability::unavailable_with(
517 UnavailableReason::RequestPreparationFailed,
518 format!("{error:#}"),
519 ),
520 tools,
521 body: Availability::unavailable_with(
522 UnavailableReason::RequestPreparationFailed,
523 format!("{error:#}"),
524 ),
525 });
526 }
527 };
528
529 // `include` on a Responses body discloses reasoning output; it does not
530 // ask the route to think. Treating any control key as a reasoning
531 // request made every Codex turn read as an explicit user selection.
532 let reasoning_resolution = if !prepared.reasoning.controls_reasoning() {
533 ReasoningResolution::NotApplicable
534 } else if reasoning_effort_auto {
535 ReasoningResolution::ResolvedFromHypotheticalPrompt
536 } else if prepared.reasoning.requested_effort.is_none() {
537 ReasoningResolution::RouteDefault
538 } else {
539 ReasoningResolution::Explicit
540 };
541
542 // Headroom and overflow both follow production's message/system
543 // estimator. When an earlier runtime transform cannot be observed
544 // without mutation, `runtime_transforms` makes this body unavailable
545 // rather than publishing a guess.
546 let input_budget_ceiling_tokens =
547 context_input_budget_for_route(provider, &model, limits, 0);
548 if crate::request_manifest::production_input_budget_exceeded(
549 input_budget_ceiling_tokens,
550 production_input_estimate_tokens,
551 ) {
552 runtime_transforms
553 .push("context-overflow recovery would trim or compact the conversation");
554 }
555
556 let route_facts = RouteFacts {
557 provider_id: SafeLabel::identifier(&prepared.endpoint.provider_id),
558 provider_display: SafeLabel::phrase(&prepared.endpoint.provider_display),
559 route_id: prepared
560 .endpoint
561 .route_id
562 .as_deref()
563 .map(SafeLabel::identifier),
564 dialect: prepared.dialect.as_str().to_string(),
565 route_shape: prepared.endpoint.shape.as_str().to_string(),
566 endpoint_host_class: prepared.safe_endpoint_host_class(),
567 endpoint_fingerprint: prepared.endpoint_fingerprint(),
568 wire_model: SafeLabel::catalog_model(&prepared.wire_model),
569 caller_entrypoint: prepared.entrypoint.as_str().to_string(),
570 body_stream_field: prepared.wire_stream_field(),
571 context_limit_tokens: route.context_window.tokens,
572 context_limit_source: route.context_window.source,
573 route_input_limit_tokens: limits.and_then(|limits| limits.input_tokens),
574 route_output_limit_tokens: limits.and_then(|limits| limits.output_tokens),
575 billing: preview_billing_facts(&route.config, provider, &base_url),
576 routing_source: routing_source.label().to_string(),
577 auto_route_source: auto_route_source.as_deref().map(SafeLabel::phrase),
578 };
579
580 let prompt = self.preview_prompt_provenance(&prepared, system_prompt_text.as_str(), &model);
581
582 // The body is a *dependent* fact. A tool surface whose MCP
583 // contribution is unknown does not yield "the same body with no MCP
584 // tools" — a real turn would connect and may send a different tool
585 // list, a different tool region, and therefore a different body,
586 // local component fingerprint, and hash. Publishing an exact body there was the reviewed
587 // defect: it fabricated an empty MCP contribution and hashed it.
588 // Likewise, a request the turn loop would rewrite before sending is
589 // not the request that would be sent.
590 let body = if let Some(inherited) = tools.propagate() {
591 inherited
592 } else if runtime_transforms.is_empty() {
593 Availability::Exact(PreparedBodyInputs {
594 prepared: &prepared,
595 reasoning_resolution,
596 prompt,
597 input_budget_ceiling_tokens,
598 production_input_estimate_tokens,
599 tool_surface_is_exact: true,
600 })
601 } else {
602 Availability::unavailable_with(
603 UnavailableReason::RuntimeTransformsBeforeSend,
604 runtime_transforms.join("; "),
605 )
606 };
607
608 RequestManifest::build(ManifestDraft {
609 session,
610 route: Availability::Exact(route_facts),
611 tools,
612 body,
613 })
614 }
615
616 /// Transforms the turn loop would apply to this conversation between
617 /// dispatch and the first provider request.
618 ///
619 /// Every check is **read-only**. Nothing here drains the steer channel,
620 /// receives a queued sub-agent completion, flushes an LSP block, or runs
621 /// compaction: an inspection that consumed pending state would change the
622 /// very turn it claims to describe. Where a queue can only be *counted*
623 /// rather than inspected, counting is what happens.
624 ///
625 /// Returned strings are compile-time constants. They are joined into a
626 /// typed unavailable detail, which still crosses the safe-label boundary.
627 async fn preview_runtime_transforms(
628 &self,
629 messages: &[Message],
630 working_set: &crate::working_set::WorkingSet,
631 compaction: &crate::compaction::CompactionConfig,
632 ) -> Vec<&'static str> {
633 let mut reasons = Vec::new();
634
635 if !self.pending_lsp_blocks.is_empty() {
636 reasons.push("pending LSP diagnostics would be injected as a synthetic message");
637 }
638
639 let shell_completion_may_be_injected = self
640 .shell_manager
641 .lock()
642 .map_or(true, |manager| manager.may_have_undelivered_completion());
643 if shell_completion_may_be_injected {
644 reasons.push("a background shell completion may be injected before the request");
645 }
646
647 let queued_completions = !self.rx_subagent_completion.is_empty() || {
648 let manager = self.subagent_manager.read().await;
649 manager.may_transform_next_parent_request(&self.delivered_subagent_completion_ids)
650 };
651 if queued_completions {
652 reasons.push("a running or undelivered sub-agent completion may be injected");
653 }
654
655 if compaction.enabled {
656 let pins = self.compaction_pins_for_messages(messages, working_set);
657 let paths = working_set.top_paths(24);
658 if should_compact(
659 messages,
660 compaction,
661 Some(&self.session.workspace),
662 Some(&pins),
663 Some(&paths),
664 ) {
665 reasons.push("auto-compaction would rewrite the conversation first");
666 }
667 }
668
669 reasons
670 }
671
672 /// Posture that depends on neither the route nor the next message.
673 fn preview_session_facts(&self, inputs: &PreviewRequestInputs) -> SessionFacts {
674 let base = crate::prompts::effective_base_prompt_text();
675 let input_policy = effective_input_policy(
676 inputs.provenance,
677 inputs.mode,
678 "",
679 inputs.allow_shell,
680 inputs.trust_mode,
681 inputs.mode == AppMode::Yolo || inputs.auto_approve,
682 inputs.approval_mode,
683 );
684 SessionFacts {
685 agent_role: "primary".to_string(),
686 lane_kind: "interactive-primary".to_string(),
687 fleet_assignment: "not-applicable-primary-agent".to_string(),
688 requested_model: SafeLabel::catalog_model(&inputs.requested_model),
689 auto_model_routing: inputs.auto_model,
690 requested_reasoning: SafeLabel::identifier(&inputs.requested_reasoning),
691 // What the caller supplied, not what planning managed to do with
692 // it: a plan failure must not read as "you forgot `--prompt`".
693 hypothetical_prompt_supplied: inputs.hypothetical_prompt_supplied,
694 mode: input_policy.mode.label().to_string(),
695 approval_mode: format!("{:?}", input_policy.approval_mode_for_session()),
696 allowed_tool_gate_count: inputs.allowed_tools.as_ref().map(Vec::len),
697 disallowed_tool_gate_count: self.config.disallowed_tools.as_ref().map(Vec::len),
698 base_prompt: BasePromptProvenance {
699 origin: crate::prompts::base_prompt_origin().label().to_string(),
700 bytes: base.len(),
701 sha256: crate::hashing::sha256_hex(base.as_bytes()),
702 },
703 }
704 }
705
706 /// System-prompt provenance, as labels and hashes only.
707 ///
708 /// `effective` is the prompt of the request being described, so an
709 /// auto-routed preview classifies the prompt it would actually send
710 /// rather than the session's currently installed one.
711 fn preview_prompt_provenance(
712 &self,
713 prepared: &PreparedOutboundRequest,
714 effective: &str,
715 model: &str,
716 ) -> PromptProvenance {
717 let base = crate::prompts::effective_base_prompt_text();
718 let configured =
719 crate::prompts::compose_default_static_layers(crate::prompts::Personality::Calm, model);
720
721 let assembly = if effective.trim().is_empty() {
722 SystemPromptAssembly::None
723 } else if effective.trim() == base.trim() {
724 SystemPromptAssembly::BaseOnly
725 } else if effective.trim() == configured.trim() {
726 SystemPromptAssembly::BaseWithConfiguredLayers
727 } else {
728 SystemPromptAssembly::BaseWithRuntimeAdditions
729 };
730
731 let view = prepared.wire_view();
732 PromptProvenance {
733 assembly,
734 // The hash of the prompt the *prepared request* carries, in its
735 // final wire form — not of an independently recomposed string.
736 effective_system_canonical_json_bytes: view.system_bytes,
737 effective_system_sha256: view.system_sha256.clone(),
738 }
739 }
740 }
741
742 /// Typed billing facts for the planned route, from the same helper the footer
743 /// and sidebar read. Every label is a compile-time constant.
744 fn preview_billing_facts(
745 config: &crate::config::Config,
746 provider: crate::config::ApiProvider,
747 base_url: &str,
748 ) -> BillingFacts {
749 if let Some(surface) = crate::pricing::billing_surface_for_route(provider, Some(base_url)) {
750 return BillingFacts::Surface { surface };
751 }
752 match crate::route_billing::for_route(config, provider) {
753 crate::route_billing::BillingPresentation::Metered => BillingFacts::Metered,
754 crate::route_billing::BillingPresentation::Subscription(plan) => {
755 BillingFacts::Subscription { plan }
756 }
757 crate::route_billing::BillingPresentation::Local => BillingFacts::Local,
758 crate::route_billing::BillingPresentation::Unknown => BillingFacts::Unknown,
759 }
760 }
761
762 /// Stable hash over the exact active tool catalog: name, description, and
763 /// schema, in catalog order. Changes when a tool is added, removed,
764 /// reordered, or has its schema transformed.
765 ///
766 /// This is the *single* definition of the active-tool-catalog digest. The
767 /// request manifest fills `ToolSurfaceFacts::active_tool_catalog_sha256` from
768 /// it, and `crate::tool_inspection` reports the same value for the same
769 /// prepared request. Neither surface keeps a digest of its own, so a human
770 /// reading `/tools` and a human reading `/request` are looking at the same
771 /// accounting object rather than two hashes that can silently diverge.
772 pub(crate) fn active_tool_catalog_sha256(tools: &[Tool]) -> String {
773 let mut canonical = String::new();
774 for tool in tools {
775 canonical.push_str(&tool.name);
776 canonical.push('\u{1}');
777 canonical.push_str(&tool.description);
778 canonical.push('\u{1}');
779 canonical.push_str(&crate::client::canonical_json(&tool.input_schema));
780 canonical.push('\n');
781 }
782 crate::hashing::sha256_hex(canonical.as_bytes())
783 }
784
785 /// Whether the Standard and Full tool surfaces currently produce the same
786 /// catalog.
787 ///
788 /// Derived, not asserted: the surface shaper is run over this exact catalog
789 /// under both budgets and the results compared. If Standard and Full ever
790 /// genuinely diverge, this reports `false` without anyone editing copy.
791 fn standard_and_full_collapse(
792 catalog: &[Tool],
793 always_load: &std::collections::HashSet<String>,
794 ) -> bool {
795 super::tool_catalog::surface_budgets_produce_same_catalog(
796 catalog,
797 always_load,
798 crate::model_profile::ToolSurfaceBudget::Standard,
799 crate::model_profile::ToolSurfaceBudget::Full,
800 )
801 }
802
803 #[cfg(test)]
804 mod tests {
805 use super::*;
806
807 fn tool(name: &str, deferred: bool) -> Tool {
808 Tool {
809 tool_type: None,
810 name: name.to_string(),
811 description: format!("{name} description"),
812 input_schema: json!({"type": "object", "properties": {}}),
813 allowed_callers: None,
814 defer_loading: Some(deferred),
815 input_examples: None,
816 strict: None,
817 cache_control: None,
818 }
819 }
820
821 #[test]
822 fn active_catalog_hash_tracks_membership_order_and_schema() {
823 let base = vec![tool("Bash", false), tool("File", false)];
824 let baseline = active_tool_catalog_sha256(&base);
825
826 assert_eq!(baseline, active_tool_catalog_sha256(&base.clone()));
827
828 let mut reordered = base.clone();
829 reordered.swap(0, 1);
830 assert_ne!(baseline, active_tool_catalog_sha256(&reordered));
831
832 let mut fewer = base.clone();
833 fewer.pop();
834 assert_ne!(baseline, active_tool_catalog_sha256(&fewer));
835
836 let mut retyped = base.clone();
837 retyped[0].input_schema = json!({"type": "object", "required": ["cmd"]});
838 assert_ne!(baseline, active_tool_catalog_sha256(&retyped));
839 }
840
841 #[test]
842 fn work_tail_context_ceiling_uses_production_headroom_and_no_send_decision() {
843 let messages = vec![Message {
844 role: "user".to_string(),
845 content: vec![ContentBlock::Text {
846 text: "near the context ceiling".to_string(),
847 cache_control: None,
848 }],
849 }];
850 let work_tail =
851 crate::work_grounding::work_state_message(&crate::tools::todo::TodoListSnapshot {
852 items: vec![crate::tools::todo::TodoItem {
853 id: 1,
854 content: "retain the separately framed Work tail".to_string(),
855 status: crate::tools::todo::TodoStatus::InProgress,
856 }],
857 completion_pct: 0,
858 in_progress_id: Some(1),
859 })
860 .expect("nonempty Work tail");
861 let system = SystemPrompt::Text("stable system".to_string());
862 let base = crate::compaction::estimate_input_tokens_conservative(&messages, Some(&system));
863 let production =
864 super::turn_loop::production_input_estimate_with_work_tail(base, Some(&work_tail));
865
866 let mut combined_messages = messages;
867 combined_messages.push(work_tail);
868 let combined_once = crate::compaction::estimate_input_tokens_conservative(
869 &combined_messages,
870 Some(&system),
871 );
872 assert!(
873 production > combined_once,
874 "the production decomposition charges a second fixed framing overhead"
875 );
876
877 // This is the reviewed edge: the old combined-once estimate would
878 // allow a send, while production is one token over its ceiling. The
879 // same signed-headroom helper now drives both the manifest number and
880 // preview's overflow/no-exact-outbound decision.
881 let ceiling = production - 1;
882 assert_eq!(
883 crate::request_manifest::production_input_headroom(Some(ceiling), production),
884 Some(-1)
885 );
886 assert!(crate::request_manifest::production_input_budget_exceeded(
887 Some(ceiling),
888 production
889 ));
890 assert!(!crate::request_manifest::production_input_budget_exceeded(
891 Some(ceiling),
892 combined_once
893 ));
894 }
895
896 #[test]
897 fn standard_and_full_are_reported_collapsed_from_the_real_shaper() {
898 let catalog = vec![tool("Bash", false), tool("agent", false), tool("Web", true)];
899 let always_load = std::collections::HashSet::new();
900 assert!(
901 standard_and_full_collapse(&catalog, &always_load),
902 "Standard and Full apply no narrowing today, so they must report collapsed"
903 );
904 }
905
906 #[tokio::test]
907 async fn pending_shell_completion_makes_the_body_unavailable_without_draining_it() {
908 let config = deepseek_config();
909 let identity = deepseek_identity();
910 let (mut engine, _handle, _tmp) = preview_engine(&config);
911 engine.config.features.disable(Feature::Mcp);
912
913 {
914 let mut manager = engine.shell_manager.lock().expect("shell manager");
915 let command = if cfg!(windows) {
916 "Start-Sleep -Seconds 30"
917 } else {
918 "sleep 30"
919 };
920 manager
921 .execute(command, None, 30_000, true)
922 .expect("background shell starts");
923 assert!(manager.may_have_undelivered_completion());
924 }
925
926 let planned = plan(&config, &identity, false, "inspect the next request").await;
927 let manifest = engine
928 .build_request_manifest(inputs(false, Some(planned), "inspect the next request"))
929 .await;
930 let unavailable = match &manifest.body {
931 Availability::Unavailable(unavailable) => unavailable,
932 Availability::Exact(_) => panic!("pending shell completion must fail closed"),
933 };
934 assert_eq!(
935 unavailable.reason,
936 UnavailableReason::RuntimeTransformsBeforeSend
937 );
938 assert!(
939 unavailable
940 .detail
941 .as_deref()
942 .is_some_and(|detail| detail.contains("background shell completion")),
943 "{unavailable:?}"
944 );
945
946 let mut manager = engine.shell_manager.lock().expect("shell manager");
947 assert!(
948 manager.may_have_undelivered_completion(),
949 "preview must not drain or report the completion"
950 );
951 let _ = manager.kill_running();
952 let _ = manager.drain_finished_jobs_with_evidence();
953 }
954
955 #[tokio::test]
956 async fn running_direct_child_fails_closed_without_consuming_or_mutating_state() {
957 let config = deepseek_config();
958 let identity = deepseek_identity();
959 let (mut engine, _handle, tmp) = preview_engine(&config);
960 engine.config.features.disable(Feature::Mcp);
961 let mut before = {
962 let mut manager = engine.subagent_manager.write().await;
963 manager.insert_test_running_direct_child("preview_pending", tmp.path());
964 serde_json::to_value(manager.list()).expect("manager snapshot")
965 };
966 if let Some(rows) = before.as_array_mut() {
967 for row in rows {
968 row.as_object_mut()
969 .expect("agent object")
970 .remove("duration_ms");
971 }
972 }
973 let delivered_before = engine.delivered_subagent_completion_ids.clone();
974
975 let planned = plan(&config, &identity, false, "inspect while child runs").await;
976 let manifest = engine
977 .build_request_manifest(inputs(false, Some(planned), "inspect while child runs"))
978 .await;
979 let unavailable = match &manifest.body {
980 Availability::Unavailable(unavailable) => unavailable,
981 Availability::Exact(_) => panic!("running child must fail closed"),
982 };
983 assert_eq!(
984 unavailable.reason,
985 UnavailableReason::RuntimeTransformsBeforeSend
986 );
987 assert!(
988 unavailable
989 .detail
990 .as_deref()
991 .is_some_and(|detail| detail.contains("running or undelivered sub-agent"))
992 );
993
994 let mut after = {
995 let manager = engine.subagent_manager.read().await;
996 assert!(
997 manager
998 .may_transform_next_parent_request(&engine.delivered_subagent_completion_ids)
999 );
1000 serde_json::to_value(manager.list()).expect("manager snapshot")
1001 };
1002 if let Some(rows) = after.as_array_mut() {
1003 for row in rows {
1004 row.as_object_mut()
1005 .expect("agent object")
1006 .remove("duration_ms");
1007 }
1008 }
1009 assert_eq!(before, after, "preview must not mutate child state");
1010 assert_eq!(
1011 delivered_before, engine.delivered_subagent_completion_ids,
1012 "preview must not claim child delivery"
1013 );
1014 }
1015
1016 #[tokio::test]
1017 async fn terminal_undelivered_child_fails_closed_without_claiming_delivery() {
1018 let config = deepseek_config();
1019 let identity = deepseek_identity();
1020 let (mut engine, _handle, tmp) = preview_engine(&config);
1021 engine.config.features.disable(Feature::Mcp);
1022 let agent_id = {
1023 let mut manager = engine.subagent_manager.write().await;
1024 manager.insert_test_terminal_direct_child("preview_terminal", tmp.path())
1025 };
1026
1027 let planned = plan(&config, &identity, false, "inspect settled child").await;
1028 let manifest = engine
1029 .build_request_manifest(inputs(false, Some(planned), "inspect settled child"))
1030 .await;
1031 assert!(matches!(manifest.body, Availability::Unavailable(_)));
1032 assert!(
1033 !engine.delivered_subagent_completion_ids.contains(&agent_id),
1034 "preview must not claim terminal delivery"
1035 );
1036 let manager = engine.subagent_manager.read().await;
1037 assert!(
1038 manager.may_transform_next_parent_request(&engine.delivered_subagent_completion_ids)
1039 );
1040 assert!(matches!(
1041 manager
1042 .get_result(&agent_id)
1043 .expect("terminal child")
1044 .status,
1045 crate::tools::subagent::SubAgentStatus::Completed
1046 ));
1047 }
1048
1049 #[test]
1050 fn turn_metadata_uses_planned_cross_route_limits_not_installed_limits() {
1051 let config = deepseek_config();
1052 let (mut engine, _handle, _tmp) = preview_engine(&config);
1053 engine.api_provider = ApiProvider::Deepseek;
1054 let installed_limits = codewhale_config::route::RouteLimits {
1055 context_tokens: Some(4_096),
1056 input_tokens: None,
1057 output_tokens: Some(512),
1058 };
1059 engine.active_route_limits = Some(installed_limits);
1060 // Large enough to be critical for the installed 4K route, but safely
1061 // below the warning threshold for the planned 123K route.
1062 engine.session.messages.push(Message {
1063 role: "user".to_string(),
1064 content: vec![ContentBlock::Text {
1065 text: "x".repeat(20_000),
1066 cache_control: None,
1067 }],
1068 });
1069 let prompt_context = NextTurnPromptContext::for_planned_turn(
1070 ApiProvider::Openrouter,
1071 "qwen/qwen3.6-flash".to_string(),
1072 Some(codewhale_config::route::RouteLimits {
1073 context_tokens: Some(123_456),
1074 input_tokens: None,
1075 output_tokens: Some(4_096),
1076 }),
1077 AppMode::Agent,
1078 None,
1079 GoalStatus::Active,
1080 None,
1081 false,
1082 None,
1083 );
1084 let system_prompt = engine.compose_stable_system_prompt(&prompt_context);
1085 assert_eq!(
1086 engine.context_pressure_line(
1087 "cross-route budget",
1088 &prompt_context,
1089 system_prompt.as_ref()
1090 ),
1091 None,
1092 "the planned 123K route must not inherit the installed route's pressure"
1093 );
1094 let installed_context = NextTurnPromptContext::for_planned_turn(
1095 ApiProvider::Deepseek,
1096 "deepseek-v4-flash".to_string(),
1097 Some(installed_limits),
1098 AppMode::Agent,
1099 None,
1100 GoalStatus::Active,
1101 None,
1102 false,
1103 None,
1104 );
1105 assert_eq!(
1106 engine
1107 .context_pressure_line("cross-route budget", &installed_context, None)
1108 .as_deref(),
1109 Some(
1110 "Context pressure: critical — CRITICAL: stop expanding scope; run /compact immediately or finish the current task"
1111 ),
1112 "control fixture must be critical under the installed 4K limits"
1113 );
1114 let message = engine.user_text_message_from_snapshot(
1115 "cross-route budget".to_string(),
1116 &prompt_context.model,
1117 true,
1118 None,
1119 false,
1120 UserInputProvenance::ExternalUser,
1121 TurnMetadataSnapshot {
1122 prompt_context: &prompt_context,
1123 system_prompt: system_prompt.as_ref(),
1124 approval_mode: crate::tui::approval::ApprovalMode::Suggest,
1125 working_set: &engine.session.working_set,
1126 policy_narrowing: None,
1127 },
1128 );
1129 let metadata = message
1130 .content
1131 .iter()
1132 .filter_map(|block| match block {
1133 ContentBlock::Text { text, .. } => Some(text.as_str()),
1134 _ => None,
1135 })
1136 .next_back()
1137 .expect("turn metadata text");
1138 assert!(
1139 !metadata.contains("Context pressure:"),
1140 "planned route metadata must remain below warning: {metadata}"
1141 );
1142 assert!(!metadata.contains("123456 tokens"), "{metadata}");
1143 assert!(!metadata.contains("4096 tokens"), "{metadata}");
1144 }
1145
1146 #[tokio::test]
1147 async fn planned_route_builds_subagent_catalog_without_installed_client() {
1148 let config = deepseek_config();
1149 let identity = deepseek_identity();
1150 let (mut engine, _handle, _tmp) = preview_engine(&config);
1151 engine.config.features.disable(Feature::Mcp);
1152 let _ = engine.config.features.enable(Feature::Subagents);
1153 engine.config.subagents_enabled = true;
1154 engine.deepseek_client = None;
1155 let planned = plan(&config, &identity, false, "planned child route").await;
1156 let route = planned.route.validate().expect("planned route validates");
1157 let planned_model = route.model.clone();
1158 let policy = TurnAuthority::from_effective_fields(
1159 AppMode::Agent,
1160 false,
1161 false,
1162 false,
1163 crate::tui::approval::ApprovalMode::Suggest,
1164 );
1165 let build = engine
1166 .build_turn_tool_registry_and_catalog(
1167 &policy,
1168 &[],
1169 None,
1170 SubAgentWiring::Inert,
1171 McpAccess::PassiveSnapshot,
1172 TurnRouteContext {
1173 provider: route.identity.provider,
1174 model: route.model.clone(),
1175 capabilities: route.candidate.capabilities(),
1176 limits: crate::route_budget::known_route_limits(route.candidate.limits()),
1177 client: Some(route.client),
1178 api_config: route.config,
1179 locale_tag: engine.config.locale_tag.clone(),
1180 role_models: engine.subagent_role_models(),
1181 fleet_roster: engine.config.fleet_roster.clone(),
1182 auto_model: false,
1183 reasoning_effort: planned.effective_reasoning_effort,
1184 reasoning_effort_auto: planned.auto_controls_reasoning,
1185 },
1186 "",
1187 )
1188 .await;
1189 assert!(
1190 build
1191 .surface
1192 .catalog
1193 .iter()
1194 .any(|tool| tool.name == "agent"),
1195 "the planned route client must make sub-agent tools available"
1196 );
1197 assert_eq!(
1198 build.subagent_runtime_model.as_deref(),
1199 Some(planned_model.as_str()),
1200 "the child runtime must carry the planned route model"
1201 );
1202 }
1203
1204 /// Auto routing with no hypothetical prompt: every route-derived fact is
1205 /// structurally absent, and the flag is never cleared just because the
1206 /// session happens to have an installed route.
1207 #[tokio::test]
1208 async fn auto_route_without_a_prompt_omits_every_final_fact() {
1209 let tmp = tempfile::tempdir().expect("tempdir");
1210 let (mut engine, _handle) = Engine::new(
1211 EngineConfig {
1212 workspace: tmp.path().to_path_buf(),
1213 ..Default::default()
1214 },
1215 &crate::config::Config::default(),
1216 );
1217
1218 let manifest = engine
1219 .build_request_manifest(PreviewRequestInputs {
1220 mode: AppMode::Agent,
1221 allow_shell: false,
1222 trust_mode: false,
1223 auto_approve: false,
1224 approval_mode: crate::tui::approval::ApprovalMode::Suggest,
1225 allowed_tools: None,
1226 dynamic_tools: Vec::new(),
1227 provenance: UserInputProvenance::ExternalUser,
1228 requested_model: "auto".to_string(),
1229 requested_reasoning: "auto".to_string(),
1230 auto_model: true,
1231 hypothetical_prompt_supplied: false,
1232 next_turn: None,
1233 unresolved: PreviewUnresolved::AutoRouteNeedsPrompt,
1234 })
1235 .await;
1236
1237 assert!(manifest.route.exact().is_none());
1238 assert!(manifest.tools.exact().is_none());
1239 assert!(manifest.body.exact().is_none());
1240 assert_eq!(manifest.session.requested_model.as_str(), "auto");
1241 assert!(manifest.session.auto_model_routing);
1242 assert!(!manifest.session.hypothetical_prompt_supplied);
1243
1244 let json = manifest.to_json();
1245 for forbidden in [
1246 "provider_id",
1247 "wire_model",
1248 "endpoint_fingerprint",
1249 "body_sha256",
1250 "tool_surface_budget",
1251 "billing",
1252 ] {
1253 assert!(!json.contains(forbidden), "{forbidden} leaked:\n{json}");
1254 }
1255 }
1256
1257 fn deepseek_config() -> crate::config::Config {
1258 let providers = crate::config::ProvidersConfig {
1259 deepseek: crate::config::ProviderConfig {
1260 api_key: Some("sk-test-deepseek".to_string()),
1261 model: Some("deepseek-chat".to_string()),
1262 ..crate::config::ProviderConfig::default()
1263 },
1264 ..crate::config::ProvidersConfig::default()
1265 };
1266 crate::config::Config {
1267 provider: Some("deepseek".to_string()),
1268 providers: Some(providers),
1269 ..crate::config::Config::default()
1270 }
1271 }
1272
1273 fn deepseek_identity() -> crate::config::ProviderIdentity {
1274 crate::config::ProviderIdentity {
1275 provider: ApiProvider::Deepseek,
1276 key: "deepseek".to_string(),
1277 exact_id: None,
1278 }
1279 }
1280
1281 /// Run the *production* route planner, provider-free: with `auto_model`
1282 /// the classifier short-circuits to the inventory heuristic under `cfg!(test)`.
1283 async fn plan(
1284 config: &crate::config::Config,
1285 identity: &crate::config::ProviderIdentity,
1286 auto_model: bool,
1287 prompt: &str,
1288 ) -> crate::turn_route_plan::PlannedTurnRoute {
1289 plan_for(
1290 config,
1291 identity,
1292 ApiProvider::Deepseek,
1293 "deepseek-chat",
1294 auto_model,
1295 prompt,
1296 )
1297 .await
1298 }
1299
1300 async fn plan_for(
1301 config: &crate::config::Config,
1302 identity: &crate::config::ProviderIdentity,
1303 provider: ApiProvider,
1304 model: &str,
1305 auto_model: bool,
1306 prompt: &str,
1307 ) -> crate::turn_route_plan::PlannedTurnRoute {
1308 plan_with_reasoning(
1309 config,
1310 identity,
1311 provider,
1312 model,
1313 auto_model,
1314 if auto_model {
1315 crate::tui::app::ReasoningEffort::Auto
1316 } else {
1317 crate::tui::app::ReasoningEffort::High
1318 },
1319 prompt,
1320 )
1321 .await
1322 }
1323
1324 /// `plan_for` with the requested reasoning tier under test control. The
1325 /// exact-route matrix needs `off` to observe a route that normalizes it
1326 /// (direct Moonshot K3 sends `low`).
1327 async fn plan_with_reasoning(
1328 config: &crate::config::Config,
1329 identity: &crate::config::ProviderIdentity,
1330 provider: ApiProvider,
1331 model: &str,
1332 auto_model: bool,
1333 reasoning_effort: crate::tui::app::ReasoningEffort,
1334 prompt: &str,
1335 ) -> crate::turn_route_plan::PlannedTurnRoute {
1336 crate::turn_route_plan::plan_turn_route(crate::turn_route_plan::TurnRoutePlanRequest {
1337 route_config: config,
1338 app_route_identity: identity,
1339 api_provider: provider,
1340 app_model: model,
1341 auto_model,
1342 reasoning_effort,
1343 mode: AppMode::Agent,
1344 content: prompt,
1345 display_text: prompt,
1346 auto_router_context: "",
1347 should_auto_resolve: auto_model,
1348 allow_auto_router_response_cache: false,
1349 preflight_required: false,
1350 auto_compact_user_configured: false,
1351 auto_compact: true,
1352 auto_compact_threshold_percent: 80.0,
1353 })
1354 .await
1355 .expect("the shared planner resolves a configured route")
1356 }
1357
1358 fn preview_engine(config: &crate::config::Config) -> (Engine, EngineHandle, tempfile::TempDir) {
1359 let tmp = tempfile::tempdir().expect("tempdir");
1360 let (engine, handle) = Engine::new(
1361 EngineConfig {
1362 workspace: tmp.path().to_path_buf(),
1363 ..Default::default()
1364 },
1365 config,
1366 );
1367 (engine, handle, tmp)
1368 }
1369
1370 fn wire_preview_engine(config: &crate::config::Config) -> (Engine, tempfile::TempDir) {
1371 let tmp = tempfile::tempdir().expect("tempdir");
1372 let (mut engine, _handle) = Engine::new(
1373 EngineConfig {
1374 workspace: tmp.path().to_path_buf(),
1375 max_steps: 1,
1376 snapshots_enabled: false,
1377 terminal_chrome_enabled: false,
1378 ..Default::default()
1379 },
1380 config,
1381 );
1382 engine.config.features.disable(Feature::Mcp);
1383 engine.config.subagents_enabled = false;
1384 (engine, tmp)
1385 }
1386
1387 fn inputs(
1388 auto_model: bool,
1389 planned: Option<crate::turn_route_plan::PlannedTurnRoute>,
1390 prompt: &str,
1391 ) -> PreviewRequestInputs {
1392 PreviewRequestInputs {
1393 mode: AppMode::Agent,
1394 allow_shell: false,
1395 trust_mode: false,
1396 auto_approve: false,
1397 approval_mode: crate::tui::approval::ApprovalMode::Suggest,
1398 allowed_tools: None,
1399 dynamic_tools: Vec::new(),
1400 provenance: UserInputProvenance::ExternalUser,
1401 requested_model: if auto_model {
1402 "auto".to_string()
1403 } else {
1404 "deepseek-chat".to_string()
1405 },
1406 requested_reasoning: if auto_model { "auto" } else { "high" }.to_string(),
1407 auto_model,
1408 hypothetical_prompt_supplied: true,
1409 next_turn: planned.map(|planned| {
1410 let prompt_context = NextTurnPromptContext::for_planned_turn(
1411 planned.route.identity.provider,
1412 planned.route.model.clone(),
1413 crate::route_budget::known_route_limits(planned.route.candidate.limits()),
1414 AppMode::Agent,
1415 None,
1416 GoalStatus::Active,
1417 None,
1418 false,
1419 None,
1420 );
1421 Box::new(PreviewNextTurn {
1422 content: prompt.to_string(),
1423 route: Box::new(planned.route),
1424 prompt_context,
1425 reasoning_effort: planned.effective_reasoning_effort,
1426 reasoning_effort_auto: planned.auto_controls_reasoning,
1427 auto_route_source: planned
1428 .auto_selection
1429 .as_ref()
1430 .map(|selection| selection.source.label().to_string()),
1431 routing_source: planned.routing_source,
1432 compaction: planned.compaction,
1433 })
1434 }),
1435 unresolved: PreviewUnresolved::NoPrompt,
1436 }
1437 }
1438
1439 /// Typed controls for the preview/wire parity fixture. Defaults mirror the
1440 /// ordinary active DeepSeek turn; individual tests override only the
1441 /// production context they are proving.
1442 struct PreviewWireFixture {
1443 goal_objective: Option<String>,
1444 goal_status: GoalStatus,
1445 translation_enabled: bool,
1446 verbosity: Option<String>,
1447 requested_model: Option<String>,
1448 requested_reasoning: Option<String>,
1449 }
1450
1451 impl Default for PreviewWireFixture {
1452 fn default() -> Self {
1453 Self {
1454 goal_objective: None,
1455 goal_status: GoalStatus::Active,
1456 translation_enabled: false,
1457 verbosity: None,
1458 requested_model: None,
1459 requested_reasoning: None,
1460 }
1461 }
1462 }
1463
1464 async fn assert_preview_matches_first_wire_body(
1465 engine: &mut Engine,
1466 server: &wiremock::MockServer,
1467 planned: crate::turn_route_plan::PlannedTurnRoute,
1468 prompt: &str,
1469 fixture: PreviewWireFixture,
1470 ) -> (RequestManifest, serde_json::Value) {
1471 let PreviewWireFixture {
1472 goal_objective,
1473 goal_status,
1474 translation_enabled,
1475 verbosity,
1476 requested_model,
1477 requested_reasoning,
1478 } = fixture;
1479 let production_route = planned.route.clone();
1480 let compaction = planned.compaction.clone();
1481 let reasoning_effort = planned.effective_reasoning_effort.clone();
1482 let reasoning_effort_auto = planned.auto_controls_reasoning;
1483 let mut preview_inputs = inputs(false, Some(planned), prompt);
1484 if let Some(requested_model) = requested_model {
1485 preview_inputs.requested_model = requested_model;
1486 }
1487 if let Some(requested_reasoning) = requested_reasoning {
1488 preview_inputs.requested_reasoning = requested_reasoning;
1489 }
1490 let next = preview_inputs.next_turn.as_mut().expect("planned preview");
1491 next.prompt_context = NextTurnPromptContext::for_planned_turn(
1492 production_route.identity.provider,
1493 production_route.model.clone(),
1494 crate::route_budget::known_route_limits(production_route.candidate.limits()),
1495 AppMode::Agent,
1496 goal_objective.clone(),
1497 goal_status,
1498 None,
1499 translation_enabled,
1500 verbosity.clone(),
1501 );
1502 let manifest = engine.build_request_manifest(preview_inputs).await;
1503 let preview_hash = manifest
1504 .body
1505 .exact()
1506 .expect("preview body is exact")
1507 .body_sha256
1508 .clone();
1509
1510 let _ = engine
1511 .handle_send_message(
1512 prompt.to_string(),
1513 AppMode::Agent,
1514 production_route,
1515 compaction,
1516 goal_objective,
1517 None,
1518 goal_status,
1519 reasoning_effort,
1520 reasoning_effort_auto,
1521 false,
1522 false,
1523 false,
1524 false,
1525 crate::tui::approval::ApprovalMode::Suggest,
1526 translation_enabled,
1527 None,
1528 Vec::new(),
1529 None,
1530 verbosity,
1531 UserInputProvenance::ExternalUser,
1532 )
1533 .await;
1534
1535 let requests = server
1536 .received_requests()
1537 .await
1538 .expect("wire mock records requests");
1539 assert_eq!(requests.len(), 1, "the fixture must make one provider call");
1540 let first_wire_body: serde_json::Value =
1541 serde_json::from_slice(&requests[0].body).expect("first HTTP body is JSON");
1542 let first_wire_hash =
1543 crate::hashing::sha256_hex(crate::client::canonical_json(&first_wire_body).as_bytes());
1544 assert_eq!(
1545 preview_hash, first_wire_hash,
1546 "preview hash must match the body captured at the HTTP boundary"
1547 );
1548 (manifest, first_wire_body)
1549 }
1550
1551 #[tokio::test]
1552 async fn graph_backed_work_tail_matches_the_first_http_body() {
1553 use wiremock::matchers::method;
1554 use wiremock::{Mock, MockServer, ResponseTemplate};
1555
1556 let server = MockServer::start().await;
1557 Mock::given(method("POST"))
1558 .respond_with(
1559 ResponseTemplate::new(200)
1560 .insert_header("content-type", "text/event-stream")
1561 .set_body_string("data: [DONE]\n\n"),
1562 )
1563 .mount(&server)
1564 .await;
1565 let mut config = deepseek_config();
1566 config
1567 .providers
1568 .as_mut()
1569 .expect("providers")
1570 .deepseek
1571 .base_url = Some(server.uri());
1572 let identity = deepseek_identity();
1573 let (mut engine, _tmp) = wire_preview_engine(&config);
1574 let graph_todos = crate::tools::todo::TodoListSnapshot {
1575 items: vec![crate::tools::todo::TodoItem {
1576 id: 1,
1577 content: "preserve this graph-authoritative Work item".to_string(),
1578 status: crate::tools::todo::TodoStatus::InProgress,
1579 }],
1580 completion_pct: 0,
1581 in_progress_id: Some(1),
1582 };
1583 let work = crate::work_graph::new_shared_work_runtime(
1584 engine.config.todos.clone(),
1585 engine.config.plan_state.clone(),
1586 );
1587 work.restore(
1588 "preview-graph-work",
1589 None,
1590 &graph_todos,
1591 &crate::tools::plan::PlanSnapshot::default(),
1592 )
1593 .expect("restore graph-backed Work state");
1594 *engine.config.todos.lock().await = crate::tools::todo::TodoList::new();
1595 assert!(
1596 engine.config.todos.lock().await.snapshot().is_empty(),
1597 "legacy projection is intentionally stale for this authority test"
1598 );
1599 engine.config.runtime_services.work = Some(work);
1600
1601 let prompt = "inspect the request with live Work state";
1602 let planned = plan(&config, &identity, false, prompt).await;
1603 let (_, first_wire_body) = assert_preview_matches_first_wire_body(
1604 &mut engine,
1605 &server,
1606 planned,
1607 prompt,
1608 PreviewWireFixture::default(),
1609 )
1610 .await;
1611 let body_text = first_wire_body.to_string();
1612 assert!(body_text.contains("<codewhale:work_state>"), "{body_text}");
1613 assert!(
1614 body_text.contains("preserve this graph-authoritative Work item"),
1615 "{body_text}"
1616 );
1617 }
1618
1619 #[tokio::test]
1620 async fn exhausted_active_goal_remains_previewable() {
1621 let config = deepseek_config();
1622 let identity = deepseek_identity();
1623 let (mut engine, _handle, _tmp) = preview_engine(&config);
1624 engine.config.features.disable(Feature::Mcp);
1625 sync_goal_state_from_host(
1626 &engine.config.goal_state,
1627 Some("finish the release"),
1628 Some(100),
1629 GoalStatus::Active,
1630 );
1631 engine
1632 .config
1633 .goal_state
1634 .lock()
1635 .expect("goal state")
1636 .record_usage(100, 0);
1637
1638 let prompt = "continue the release";
1639 let planned = plan(&config, &identity, false, prompt).await;
1640 let manifest = engine
1641 .build_request_manifest(inputs(false, Some(planned), prompt))
1642 .await;
1643 assert!(manifest.route.exact().is_some());
1644 assert!(manifest.tools.exact().is_some());
1645 assert!(manifest.body.exact().is_some());
1646 }
1647
1648 #[tokio::test]
1649 async fn resumed_goal_with_raised_budget_becomes_previewable_again() {
1650 let config = deepseek_config();
1651 let identity = deepseek_identity();
1652 let (mut engine, _handle, _tmp) = preview_engine(&config);
1653 engine.config.features.disable(Feature::Mcp);
1654 sync_goal_state_from_host(
1655 &engine.config.goal_state,
1656 Some("finish the release"),
1657 Some(100),
1658 GoalStatus::Active,
1659 );
1660 {
1661 let mut state = engine.config.goal_state.lock().expect("goal state");
1662 state.record_usage(100, 0);
1663 state
1664 .mark_paused(GoalPauseReason::BudgetLimit)
1665 .expect("pause goal");
1666 }
1667 sync_goal_state_from_host(
1668 &engine.config.goal_state,
1669 Some("finish the release"),
1670 Some(200),
1671 GoalStatus::Active,
1672 );
1673
1674 let prompt = "continue under the raised budget";
1675 let planned = plan(&config, &identity, false, prompt).await;
1676 let manifest = engine
1677 .build_request_manifest(inputs(false, Some(planned), prompt))
1678 .await;
1679 assert!(manifest.body.exact().is_some());
1680 }
1681
1682 #[tokio::test]
1683 async fn lowering_active_goal_budget_below_used_tokens_keeps_preview_open() {
1684 let config = deepseek_config();
1685 let identity = deepseek_identity();
1686 let (mut engine, _handle, _tmp) = preview_engine(&config);
1687 engine.config.features.disable(Feature::Mcp);
1688 sync_goal_state_from_host(
1689 &engine.config.goal_state,
1690 Some("finish the release"),
1691 Some(200),
1692 GoalStatus::Active,
1693 );
1694 engine
1695 .config
1696 .goal_state
1697 .lock()
1698 .expect("goal state")
1699 .record_usage(100, 0);
1700 sync_goal_state_from_host(
1701 &engine.config.goal_state,
1702 Some("finish the release"),
1703 Some(50),
1704 GoalStatus::Active,
1705 );
1706
1707 let prompt = "continue after lowering the budget";
1708 let planned = plan(&config, &identity, false, prompt).await;
1709 let manifest = engine
1710 .build_request_manifest(inputs(false, Some(planned), prompt))
1711 .await;
1712 assert!(manifest.route.exact().is_some());
1713 assert!(manifest.tools.exact().is_some());
1714 assert!(manifest.body.exact().is_some());
1715 }
1716
1717 #[tokio::test]
1718 async fn translation_prompt_context_matches_captured_first_production_body() {
1719 use wiremock::matchers::method;
1720 use wiremock::{Mock, MockServer, ResponseTemplate};
1721
1722 let server = MockServer::start().await;
1723 Mock::given(method("POST"))
1724 .respond_with(
1725 ResponseTemplate::new(200)
1726 .insert_header("content-type", "text/event-stream")
1727 .set_body_string("data: [DONE]\n\n"),
1728 )
1729 .mount(&server)
1730 .await;
1731 let mut config = deepseek_config();
1732 config
1733 .providers
1734 .as_mut()
1735 .expect("providers")
1736 .deepseek
1737 .base_url = Some(server.uri());
1738 let identity = deepseek_identity();
1739 let (mut engine, _tmp) = wire_preview_engine(&config);
1740 engine.config.translation_enabled = false;
1741 let planned = plan(&config, &identity, false, "/translate explain this").await;
1742 let _ = assert_preview_matches_first_wire_body(
1743 &mut engine,
1744 &server,
1745 planned,
1746 "/translate explain this",
1747 PreviewWireFixture {
1748 translation_enabled: true,
1749 verbosity: Some("concise".to_string()),
1750 ..Default::default()
1751 },
1752 )
1753 .await;
1754 }
1755
1756 #[tokio::test]
1757 async fn paused_detach_goal_context_matches_captured_first_production_body() {
1758 use wiremock::matchers::method;
1759 use wiremock::{Mock, MockServer, ResponseTemplate};
1760
1761 let server = MockServer::start().await;
1762 Mock::given(method("POST"))
1763 .respond_with(
1764 ResponseTemplate::new(200)
1765 .insert_header("content-type", "text/event-stream")
1766 .set_body_string("data: [DONE]\n\n"),
1767 )
1768 .mount(&server)
1769 .await;
1770 let mut config = deepseek_config();
1771 config
1772 .providers
1773 .as_mut()
1774 .expect("providers")
1775 .deepseek
1776 .base_url = Some(server.uri());
1777 let identity = deepseek_identity();
1778 let (mut engine, _tmp) = wire_preview_engine(&config);
1779 engine.config.goal_objective = Some("stale paused objective".to_string());
1780 sync_goal_state_from_host(
1781 &engine.config.goal_state,
1782 Some("stale paused objective"),
1783 None,
1784 GoalStatus::Active,
1785 );
1786 let prompt = "answer only this new question\n\nCodewhale paused custom slash command context:\nThe user is not resuming that paused command.";
1787 let planned = plan(&config, &identity, false, prompt).await;
1788 let (_, first_wire_body) = assert_preview_matches_first_wire_body(
1789 &mut engine,
1790 &server,
1791 planned,
1792 prompt,
1793 PreviewWireFixture::default(),
1794 )
1795 .await;
1796 assert!(
1797 !first_wire_body
1798 .to_string()
1799 .contains("stale paused objective"),
1800 "detached paused goal leaked onto the first wire body"
1801 );
1802 }
1803
1804 #[tokio::test]
1805 async fn anthropic_preview_matches_the_first_native_messages_wire_body() {
1806 use wiremock::matchers::{method, path};
1807 use wiremock::{Mock, MockServer, ResponseTemplate};
1808
1809 let server = MockServer::start().await;
1810 Mock::given(method("POST"))
1811 .and(path("/v1/messages"))
1812 .respond_with(
1813 ResponseTemplate::new(200)
1814 .insert_header("content-type", "text/event-stream")
1815 .set_body_string("data: {\"type\":\"message_stop\"}\n\n"),
1816 )
1817 .mount(&server)
1818 .await;
1819 let model = "claude-sonnet-4-6";
1820 let config = crate::config::Config {
1821 provider: Some("anthropic".to_string()),
1822 providers: Some(crate::config::ProvidersConfig {
1823 anthropic: crate::config::ProviderConfig {
1824 api_key: Some("test-anthropic-key".to_string()),
1825 base_url: Some(server.uri()),
1826 model: Some(model.to_string()),
1827 ..crate::config::ProviderConfig::default()
1828 },
1829 ..crate::config::ProvidersConfig::default()
1830 }),
1831 ..crate::config::Config::default()
1832 };
1833 let identity = crate::config::ProviderIdentity {
1834 provider: ApiProvider::Anthropic,
1835 key: "anthropic".to_string(),
1836 exact_id: None,
1837 };
1838 let (mut engine, _tmp) = wire_preview_engine(&config);
1839 let prompt = "inspect the native Messages payload";
1840 let planned = plan_for(
1841 &config,
1842 &identity,
1843 ApiProvider::Anthropic,
1844 model,
1845 false,
1846 prompt,
1847 )
1848 .await;
1849 let (_, first_wire_body) = assert_preview_matches_first_wire_body(
1850 &mut engine,
1851 &server,
1852 planned,
1853 prompt,
1854 PreviewWireFixture::default(),
1855 )
1856 .await;
1857
1858 assert!(first_wire_body.get("system").is_some());
1859 assert!(first_wire_body.get("messages").is_some());
1860 assert!(first_wire_body.get("input").is_none());
1861 }
1862
1863 // ---------------------------------------------------------------------
1864 // #4707 — provider-free exact-route request/receipt matrix.
1865 //
1866 // The four route wire-truths are already pinned at the client boundary
1867 // (`client.rs`, `client/chat.rs`). What was missing is the *join*: that the
1868 // manifest a user reads from `/preview-request` describes the very bytes
1869 // those routes put on the wire. Each case below runs the production
1870 // planner, previews, then sends one turn through a local capture server
1871 // with the semantic endpoint left exact, and asserts the manifest against
1872 // the captured body — hash, sizes, route facts, and the requested→effective
1873 // reasoning triple.
1874 // ---------------------------------------------------------------------
1875
1876 /// One exact provider route in the matrix.
1877 struct MatrixRoute {
1878 /// Test-facing name; also the failure-message prefix.
1879 name: &'static str,
1880 provider: ApiProvider,
1881 provider_key: &'static str,
1882 base_url: &'static str,
1883 model: &'static str,
1884 requested_reasoning: crate::tui::app::ReasoningEffort,
1885 requested_reasoning_label: &'static str,
1886 /// Reasoning-control keys the manifest must report, in receipt order.
1887 expect_control_keys: &'static [&'static str],
1888 /// Effort actually on the wire — `None` when the route publishes a
1889 /// thinking toggle with no granularity. Never a fabricated tier.
1890 expect_wire_effort: Option<&'static str>,
1891 expect_wire_effort_source: Option<&'static str>,
1892 /// The output-cap key this route writes, and the one it must not.
1893 expect_output_cap_key: &'static str,
1894 expect_absent_output_cap_key: &'static str,
1895 }
1896
1897 fn glm_5_2_zai_coding() -> MatrixRoute {
1898 MatrixRoute {
1899 name: "GLM-5.2 @ Z.ai coding",
1900 provider: ApiProvider::Zai,
1901 provider_key: "zai",
1902 base_url: crate::config::DEFAULT_ZAI_BASE_URL,
1903 model: crate::config::ZAI_GLM_5_2_MODEL,
1904 requested_reasoning: crate::tui::app::ReasoningEffort::High,
1905 requested_reasoning_label: "high",
1906 expect_control_keys: &["reasoning_effort", "thinking"],
1907 expect_wire_effort: Some("high"),
1908 expect_wire_effort_source: Some("reasoning_effort"),
1909 expect_output_cap_key: "max_tokens",
1910 expect_absent_output_cap_key: "max_completion_tokens",
1911 }
1912 }
1913
1914 fn glm_5_turbo_zai() -> MatrixRoute {
1915 MatrixRoute {
1916 name: "GLM-5-Turbo @ Z.ai",
1917 provider: ApiProvider::Zai,
1918 provider_key: "zai",
1919 base_url: crate::config::DEFAULT_ZAI_BASE_URL,
1920 model: crate::config::ZAI_GLM_5_TURBO_MODEL,
1921 requested_reasoning: crate::tui::app::ReasoningEffort::High,
1922 requested_reasoning_label: "high",
1923 // No invented granularity: the toggle ships, the tier does not.
1924 expect_control_keys: &["thinking"],
1925 expect_wire_effort: None,
1926 expect_wire_effort_source: None,
1927 expect_output_cap_key: "max_tokens",
1928 expect_absent_output_cap_key: "max_completion_tokens",
1929 }
1930 }
1931
1932 fn kimi_k3_moonshot_direct() -> MatrixRoute {
1933 MatrixRoute {
1934 name: "kimi-k3 @ api.moonshot.ai",
1935 provider: ApiProvider::Moonshot,
1936 provider_key: "moonshot",
1937 base_url: crate::config::DEFAULT_MOONSHOT_BASE_URL,
1938 model: crate::config::MOONSHOT_KIMI_K3_MODEL,
1939 // The visible normalization: `off` is not a tier this route has.
1940 requested_reasoning: crate::tui::app::ReasoningEffort::Off,
1941 requested_reasoning_label: "off",
1942 expect_control_keys: &["reasoning_effort"],
1943 expect_wire_effort: Some("low"),
1944 expect_wire_effort_source: Some("reasoning_effort"),
1945 expect_output_cap_key: "max_completion_tokens",
1946 expect_absent_output_cap_key: "max_tokens",
1947 }
1948 }
1949
1950 fn k3_kimi_code() -> MatrixRoute {
1951 MatrixRoute {
1952 name: "k3 @ api.kimi.com/coding/v1",
1953 provider: ApiProvider::Moonshot,
1954 provider_key: "moonshot",
1955 base_url: crate::config::DEFAULT_KIMI_CODE_BASE_URL,
1956 model: crate::config::KIMI_CODE_K3_MODEL,
1957 requested_reasoning: crate::tui::app::ReasoningEffort::Off,
1958 requested_reasoning_label: "off",
1959 expect_control_keys: &["thinking"],
1960 expect_wire_effort: Some("low"),
1961 expect_wire_effort_source: Some("thinking.effort"),
1962 expect_output_cap_key: "max_tokens",
1963 expect_absent_output_cap_key: "max_completion_tokens",
1964 }
1965 }
1966
1967 fn minimax_m3() -> MatrixRoute {
1968 MatrixRoute {
1969 name: "MiniMax-M3 @ api.minimax.io",
1970 provider: ApiProvider::Minimax,
1971 provider_key: "minimax",
1972 base_url: crate::config::DEFAULT_MINIMAX_BASE_URL,
1973 model: crate::config::DEFAULT_MINIMAX_MODEL,
1974 requested_reasoning: crate::tui::app::ReasoningEffort::High,
1975 requested_reasoning_label: "high",
1976 expect_control_keys: &["thinking", "reasoning_split"],
1977 expect_wire_effort: None,
1978 expect_wire_effort_source: None,
1979 expect_output_cap_key: "max_completion_tokens",
1980 expect_absent_output_cap_key: "max_tokens",
1981 }
1982 }
1983
1984 fn matrix_routes() -> Vec<MatrixRoute> {
1985 vec![
1986 glm_5_2_zai_coding(),
1987 glm_5_turbo_zai(),
1988 kimi_k3_moonshot_direct(),
1989 k3_kimi_code(),
1990 minimax_m3(),
1991 ]
1992 }
1993
1994 fn matrix_config(route: &MatrixRoute) -> crate::config::Config {
1995 let entry = crate::config::ProviderConfig {
1996 api_key: Some(format!("sk-test-{}-matrix-key", route.provider_key)),
1997 base_url: Some(route.base_url.to_string()),
1998 model: Some(route.model.to_string()),
1999 ..crate::config::ProviderConfig::default()
2000 };
2001 let mut providers = crate::config::ProvidersConfig::default();
2002 match route.provider {
2003 ApiProvider::Zai => providers.zai = entry,
2004 ApiProvider::Moonshot => providers.moonshot = entry,
2005 ApiProvider::Minimax => providers.minimax = entry,
2006 other => panic!("{}: unhandled matrix provider {other:?}", route.name),
2007 }
2008 crate::config::Config {
2009 provider: Some(route.provider_key.to_string()),
2010 providers: Some(providers),
2011 ..crate::config::Config::default()
2012 }
2013 }
2014
2015 fn matrix_identity(route: &MatrixRoute) -> crate::config::ProviderIdentity {
2016 crate::config::ProviderIdentity {
2017 provider: route.provider,
2018 key: route.provider_key.to_string(),
2019 exact_id: None,
2020 }
2021 }
2022
2023 /// Plan the exact route through production, then redirect only the
2024 /// *transport* at the local capture server. The endpoint identity the
2025 /// route shaper reads is untouched, so the captured body is the body
2026 /// `api.z.ai` / `api.moonshot.ai` / `api.kimi.com` / `api.minimax.io`
2027 /// would have received.
2028 async fn matrix_planned_route(
2029 route: &MatrixRoute,
2030 config: &crate::config::Config,
2031 transport_base_url: Option<&str>,
2032 prompt: &str,
2033 ) -> crate::turn_route_plan::PlannedTurnRoute {
2034 let identity = matrix_identity(route);
2035 let mut planned = plan_with_reasoning(
2036 config,
2037 &identity,
2038 route.provider,
2039 route.model,
2040 false,
2041 route.requested_reasoning,
2042 prompt,
2043 )
2044 .await;
2045 if let Some(transport_base_url) = transport_base_url {
2046 let validated = planned
2047 .route
2048 .clone()
2049 .validate()
2050 .expect("the matrix route validates into a concrete client");
2051 let mut client = validated.client.clone();
2052 client.set_test_chat_transport_base_url(transport_base_url.to_string());
2053 planned.route = crate::route_runtime::ValidatedRuntimeRoute {
2054 client,
2055 ..validated
2056 }
2057 .into_resolved();
2058 }
2059 planned
2060 }
2061
2062 /// Non-system messages on a Chat Completions body — the manifest counts
2063 /// the system region separately, so `message_count` must exclude it.
2064 fn non_system_message_count(body: &serde_json::Value) -> usize {
2065 body.get("messages")
2066 .and_then(serde_json::Value::as_array)
2067 .map(|messages| {
2068 messages
2069 .iter()
2070 .filter(|message| {
2071 message.get("role").and_then(serde_json::Value::as_str) != Some("system")
2072 })
2073 .count()
2074 })
2075 .unwrap_or_default()
2076 }
2077
2078 async fn assert_matrix_route(route: &MatrixRoute) {
2079 use wiremock::matchers::method;
2080 use wiremock::{Mock, MockServer, ResponseTemplate};
2081
2082 let name = route.name;
2083 let server = MockServer::start().await;
2084 Mock::given(method("POST"))
2085 .respond_with(
2086 ResponseTemplate::new(200)
2087 .insert_header("content-type", "text/event-stream")
2088 .set_body_string("data: [DONE]\n\n"),
2089 )
2090 .mount(&server)
2091 .await;
2092
2093 let config = matrix_config(route);
2094 let (mut engine, _tmp) = wire_preview_engine(&config);
2095 let prompt = "inspect the exact next request for this route";
2096 let uri = server.uri();
2097 let planned = matrix_planned_route(route, &config, Some(uri.as_str()), prompt).await;
2098 let planned_provider = planned.route.identity.provider;
2099 let planned_base_url = planned.route.candidate.endpoint().base_url.clone();
2100 let planned_model = planned.route.model.clone();
2101 let expected_wire_model = crate::config::wire_model_for_provider_route(
2102 planned_provider,
2103 &planned_base_url,
2104 &planned_model,
2105 );
2106 assert_eq!(
2107 planned_base_url.trim_end_matches('/'),
2108 route.base_url.trim_end_matches('/'),
2109 "{name}: the planner must keep the exact configured endpoint"
2110 );
2111
2112 let (manifest, body) = assert_preview_matches_first_wire_body(
2113 &mut engine,
2114 &server,
2115 planned,
2116 prompt,
2117 PreviewWireFixture {
2118 requested_model: Some(route.model.to_string()),
2119 requested_reasoning: Some(route.requested_reasoning_label.to_string()),
2120 ..Default::default()
2121 },
2122 )
2123 .await;
2124
2125 // --- Route facts -------------------------------------------------
2126 let facts = manifest
2127 .route
2128 .exact()
2129 .expect("a configured fixed route is exact");
2130 assert_eq!(facts.provider_id.as_str(), route.provider_key, "{name}");
2131 assert_eq!(facts.wire_model.as_str(), expected_wire_model, "{name}");
2132 assert_eq!(facts.dialect, "chat-completions", "{name}");
2133 assert_eq!(facts.routing_source, "active-fixed-route", "{name}");
2134 assert_eq!(
2135 body.get("model").and_then(serde_json::Value::as_str),
2136 Some(expected_wire_model.as_str()),
2137 "{name}: the manifest's wire model must be the model on the wire: {body}"
2138 );
2139
2140 // --- Body identity, byte accounting, and size estimates ----------
2141 let facts_body = manifest.body.exact().expect("a prompted body is exact");
2142 let canonical = crate::client::canonical_json(&body);
2143 assert_eq!(
2144 facts_body.body_sha256,
2145 crate::hashing::sha256_hex(canonical.as_bytes()),
2146 "{name}: manifest body hash must equal the captured wire body hash"
2147 );
2148 assert_eq!(
2149 facts_body.body_canonical_json_bytes,
2150 canonical.len(),
2151 "{name}: canonical body size must describe the captured body"
2152 );
2153 assert_eq!(
2154 facts_body.system_canonical_json_bytes
2155 + facts_body.tool_schema_canonical_json_bytes
2156 + facts_body.message_canonical_json_bytes
2157 + facts_body.framing_canonical_json_bytes,
2158 facts_body.body_canonical_json_bytes,
2159 "{name}: the four accounting classes must sum to the body"
2160 );
2161 assert!(
2162 facts_body.system_canonical_json_bytes > 0,
2163 "{name}: this route sends a system region"
2164 );
2165 assert!(
2166 facts_body.tool_schema_canonical_json_bytes > 0,
2167 "{name}: this route sends tool schemas"
2168 );
2169 assert!(
2170 facts_body.message_canonical_json_bytes > 0,
2171 "{name}: this route sends messages"
2172 );
2173 assert_eq!(
2174 facts_body.message_count,
2175 non_system_message_count(&body),
2176 "{name}: message_count counts the non-system messages on the wire"
2177 );
2178 assert!(
2179 facts_body.tool_result_canonical_json_bytes <= facts_body.message_canonical_json_bytes,
2180 "{name}: tool results are a subset of messages"
2181 );
2182 assert!(
2183 facts_body.attachment_canonical_json_bytes <= facts_body.message_canonical_json_bytes,
2184 "{name}: attachments are a subset of messages"
2185 );
2186 assert_eq!(
2187 facts_body.tool_schema_wire_sha256,
2188 body.get("tools").map(|tools| {
2189 crate::hashing::sha256_hex(crate::client::canonical_json(tools).as_bytes())
2190 }),
2191 "{name}: the tool-schema digest must be over the schemas on the wire"
2192 );
2193 assert!(
2194 facts_body.estimates.system > 0 && facts_body.estimates.tool_schemas > 0,
2195 "{name}: per-class estimates are derived from the same wire regions"
2196 );
2197 assert!(
2198 facts_body.estimates.total_conservative > 0,
2199 "{name}: a whole-body estimate is available"
2200 );
2201
2202 // --- Output cap: exactly the key this route writes ----------------
2203 let wire_cap = body
2204 .get(route.expect_output_cap_key)
2205 .and_then(serde_json::Value::as_u64);
2206 assert!(
2207 wire_cap.is_some(),
2208 "{name}: expected `{}` on the wire: {body}",
2209 route.expect_output_cap_key
2210 );
2211 assert!(
2212 body.get(route.expect_absent_output_cap_key).is_none(),
2213 "{name}: `{}` must not be on the wire: {body}",
2214 route.expect_absent_output_cap_key
2215 );
2216 assert_eq!(
2217 facts_body.wire_output_cap_tokens, wire_cap,
2218 "{name}: the reported output cap is the one literally on the wire"
2219 );
2220
2221 // --- requested → effective reasoning ------------------------------
2222 assert_eq!(
2223 manifest.session.requested_model.as_str(),
2224 route.model,
2225 "{name}"
2226 );
2227 assert_eq!(
2228 manifest.session.requested_reasoning.as_str(),
2229 route.requested_reasoning_label,
2230 "{name}"
2231 );
2232 assert_eq!(
2233 facts_body.reasoning_resolution,
2234 ReasoningResolution::Explicit,
2235 "{name}: a fixed route with an explicitly requested tier"
2236 );
2237 assert_eq!(
2238 facts_body.reasoning_wire_control_keys, route.expect_control_keys,
2239 "{name}: reasoning-control keys, against the captured body {body}"
2240 );
2241 assert_eq!(
2242 facts_body
2243 .reasoning_wire_effort
2244 .as_ref()
2245 .map(|effort| effort.as_str()),
2246 route.expect_wire_effort,
2247 "{name}: wire effort, against the captured body {body}"
2248 );
2249 assert_eq!(
2250 facts_body.reasoning_wire_effort_source.as_deref(),
2251 route.expect_wire_effort_source,
2252 "{name}"
2253 );
2254 // Every reported control key is genuinely present on the wire, and the
2255 // reported effort is genuinely readable at the reported key path.
2256 for key in &facts_body.reasoning_wire_control_keys {
2257 assert!(
2258 body.get(key).is_some(),
2259 "{name}: reported control key `{key}` is not on the wire: {body}"
2260 );
2261 }
2262 match (
2263 facts_body.reasoning_wire_effort_source.as_deref(),
2264 route.expect_wire_effort,
2265 ) {
2266 (Some("reasoning_effort"), Some(effort)) => assert_eq!(
2267 body.get("reasoning_effort")
2268 .and_then(serde_json::Value::as_str),
2269 Some(effort),
2270 "{name}: {body}"
2271 ),
2272 (Some(path), Some(effort)) => {
2273 let pointer = format!("/{}", path.replace('.', "/"));
2274 assert_eq!(
2275 body.pointer(&pointer).and_then(serde_json::Value::as_str),
2276 Some(effort),
2277 "{name}: {body}"
2278 );
2279 }
2280 (None, None) => assert!(
2281 body.get("reasoning_effort").is_none(),
2282 "{name}: no effort was reported, so none may be on the wire: {body}"
2283 ),
2284 (source, effort) => panic!("{name}: inconsistent effort receipt {source:?}/{effort:?}"),
2285 }
2286
2287 // --- Provider-authoritative usage ---------------------------------
2288 // A preview describes a request that has not been sent. Unknown stays
2289 // unknown; it never becomes a zero.
2290 assert!(
2291 matches!(
2292 &facts_body.provider_reported_usage,
2293 Availability::Unavailable(unavailable)
2294 if unavailable.reason == UnavailableReason::ProviderRequestNotExecuted
2295 ),
2296 "{name}: preview must not claim provider usage"
2297 );
2298 let json = manifest.to_json();
2299 assert!(
2300 !json.contains("\"input_tokens\""),
2301 "{name}: no fabricated usage counters reach the surface:\n{json}"
2302 );
2303 }
2304
2305 #[tokio::test]
2306 async fn matrix_glm_5_2_zai_coding_preview_matches_the_first_wire_body() {
2307 assert_matrix_route(&glm_5_2_zai_coding()).await;
2308 }
2309
2310 #[tokio::test]
2311 async fn matrix_glm_5_turbo_zai_preview_matches_the_first_wire_body() {
2312 assert_matrix_route(&glm_5_turbo_zai()).await;
2313 }
2314
2315 #[tokio::test]
2316 async fn matrix_kimi_k3_moonshot_direct_preview_matches_the_first_wire_body() {
2317 assert_matrix_route(&kimi_k3_moonshot_direct()).await;
2318 }
2319
2320 #[tokio::test]
2321 async fn matrix_k3_kimi_code_preview_matches_the_first_wire_body() {
2322 assert_matrix_route(&k3_kimi_code()).await;
2323 }
2324
2325 #[tokio::test]
2326 async fn matrix_minimax_m3_preview_matches_the_first_wire_body() {
2327 assert_matrix_route(&minimax_m3()).await;
2328 }
2329
2330 /// The active tool-catalog hash is a *catalog identity*, not a wire fact:
2331 /// the same catalog under the same posture must hash the same on every
2332 /// route, however differently each dialect then shapes those schemas.
2333 /// (Unit-level membership/order/schema sensitivity is pinned by
2334 /// `active_catalog_hash_tracks_membership_order_and_schema`.)
2335 #[tokio::test]
2336 async fn matrix_routes_share_one_active_tool_catalog_hash() {
2337 let workspace = tempfile::tempdir().expect("tempdir");
2338 let prompt = "describe the shared tool catalog";
2339 let mut observed: Vec<(&'static str, usize, String, String)> = Vec::new();
2340
2341 for route in matrix_routes() {
2342 let config = matrix_config(&route);
2343 let (mut engine, _handle) = Engine::new(
2344 EngineConfig {
2345 workspace: workspace.path().to_path_buf(),
2346 max_steps: 1,
2347 snapshots_enabled: false,
2348 terminal_chrome_enabled: false,
2349 ..Default::default()
2350 },
2351 &config,
2352 );
2353 engine.config.features.disable(Feature::Mcp);
2354 engine.config.subagents_enabled = false;
2355
2356 let planned = matrix_planned_route(&route, &config, None, prompt).await;
2357 let manifest = engine
2358 .build_request_manifest(inputs(false, Some(planned), prompt))
2359 .await;
2360 let tools = manifest
2361 .tools
2362 .exact()
2363 .expect("MCP is off, so the tool surface is exact");
2364 assert!(
2365 tools.standard_and_full_surfaces_collapsed,
2366 "{}: this fixture's catalog fits both budgets, so the surface \
2367 budget label cannot change catalog membership",
2368 route.name
2369 );
2370 observed.push((
2371 route.name,
2372 tools.active_tool_count,
2373 tools.active_tool_catalog_sha256.clone(),
2374 tools.tool_surface_budget.clone(),
2375 ));
2376 }
2377
2378 assert_eq!(observed.len(), 5, "every matrix route is represented");
2379 let (first_name, first_count, first_hash, _) = observed[0].clone();
2380 for (name, count, hash, _) in &observed {
2381 assert_eq!(
2382 *count, first_count,
2383 "{name} vs {first_name}: the matrix fixture holds the tool surface constant"
2384 );
2385 assert_eq!(
2386 hash, &first_hash,
2387 "{name} vs {first_name}: one catalog must hash to one identity across routes"
2388 );
2389 }
2390
2391 // …and the routes really are distinct in capability posture: the shared
2392 // hash is a genuine cross-route agreement, not five copies of one
2393 // route. GLM-5.2 publishes a `Full` tool surface budget while
2394 // GLM-5-Turbo publishes `Standard`, and the catalog identity is
2395 // unchanged by that difference.
2396 let budgets: std::collections::BTreeSet<&str> = observed
2397 .iter()
2398 .map(|(_, _, _, budget)| budget.as_str())
2399 .collect();
2400 assert!(
2401 budgets.len() > 1,
2402 "the matrix spans routes with different surface budgets: {budgets:?}"
2403 );
2404 }
2405
2406 /// Provider-authoritative usage is never a preview fact, and it is never a
2407 /// zero standing in for "not measured". It becomes knowable only when a
2408 /// response reports it, through the same `parse_usage` seam the turn loop
2409 /// uses.
2410 #[tokio::test]
2411 async fn provider_reported_usage_is_unavailable_until_a_response_reports_it() {
2412 use wiremock::matchers::method;
2413 use wiremock::{Mock, MockServer, ResponseTemplate};
2414
2415 let usage = json!({"prompt_tokens": 137, "completion_tokens": 24, "total_tokens": 161});
2416 let stream = format!(
2417 "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n",
2418 json!({
2419 "choices": [{"index": 0, "delta": {"content": "ok"}}],
2420 }),
2421 json!({
2422 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
2423 "usage": usage,
2424 })
2425 );
2426
2427 let server = MockServer::start().await;
2428 Mock::given(method("POST"))
2429 .respond_with(
2430 ResponseTemplate::new(200)
2431 .insert_header("content-type", "text/event-stream")
2432 .set_body_string(stream),
2433 )
2434 .mount(&server)
2435 .await;
2436
2437 let mut config = deepseek_config();
2438 config
2439 .providers
2440 .as_mut()
2441 .expect("providers")
2442 .deepseek
2443 .base_url = Some(server.uri());
2444 let identity = deepseek_identity();
2445 let (mut engine, _tmp) = wire_preview_engine(&config);
2446
2447 let prompt = "count the tokens this turn will report";
2448 let planned = plan(&config, &identity, false, prompt).await;
2449 let production_route = planned.route.clone();
2450 let compaction = planned.compaction.clone();
2451 let reasoning_effort = planned.effective_reasoning_effort.clone();
2452 let reasoning_effort_auto = planned.auto_controls_reasoning;
2453
2454 let manifest = engine
2455 .build_request_manifest(inputs(false, Some(planned), prompt))
2456 .await;
2457 let body = manifest.body.exact().expect("a prompted body is exact");
2458 assert!(
2459 matches!(
2460 &body.provider_reported_usage,
2461 Availability::Unavailable(unavailable)
2462 if unavailable.reason == UnavailableReason::ProviderRequestNotExecuted
2463 ),
2464 "no request occurred, so there is nothing the provider reported"
2465 );
2466 assert_eq!(
2467 engine.session.total_usage.input_tokens, 0,
2468 "and nothing has been recorded yet"
2469 );
2470 assert_eq!(engine.session.total_usage.output_tokens, 0);
2471
2472 let _ = engine
2473 .handle_send_message(
2474 prompt.to_string(),
2475 AppMode::Agent,
2476 production_route,
2477 compaction,
2478 None,
2479 None,
2480 GoalStatus::Active,
2481 reasoning_effort,
2482 reasoning_effort_auto,
2483 false,
2484 false,
2485 false,
2486 false,
2487 crate::tui::approval::ApprovalMode::Suggest,
2488 false,
2489 None,
2490 Vec::new(),
2491 None,
2492 None,
2493 UserInputProvenance::ExternalUser,
2494 )
2495 .await;
2496
2497 // The completed turn's counts are exactly what `parse_usage` reads off
2498 // the reported usage object — no rounding, no substituted estimate.
2499 let parsed = crate::client::parse_usage(Some(&usage));
2500 assert_eq!(u64::from(parsed.input_tokens), 137);
2501 assert_eq!(u64::from(parsed.output_tokens), 24);
2502 assert_eq!(
2503 (
2504 engine.session.total_usage.input_tokens,
2505 engine.session.total_usage.output_tokens
2506 ),
2507 (
2508 u64::from(parsed.input_tokens),
2509 u64::from(parsed.output_tokens)
2510 ),
2511 "the turn records the provider-authoritative counts, not an estimate"
2512 );
2513 let reported = crate::request_manifest::ProviderReportedUsage {
2514 input_tokens: engine.session.total_usage.input_tokens,
2515 output_tokens: engine.session.total_usage.output_tokens,
2516 };
2517 assert_eq!(reported.input_tokens, 137);
2518 assert_eq!(reported.output_tokens, 24);
2519 }
2520
2521 /// A fixed route with a hypothetical prompt describes the next turn
2522 /// exactly: route, tools, and body are all published, and the prompt is
2523 /// part of the hashed body.
2524 #[tokio::test]
2525 async fn fixed_route_with_a_prompt_describes_the_exact_next_turn() {
2526 let mut config = deepseek_config();
2527 config
2528 .providers
2529 .as_mut()
2530 .expect("providers")
2531 .deepseek
2532 .context_window = Some(123_456);
2533 let identity = deepseek_identity();
2534 let (mut engine, _handle, _tmp) = preview_engine(&config);
2535 engine.config.features.disable(Feature::Mcp);
2536 engine.active_route_limits = Some(codewhale_config::route::RouteLimits {
2537 context_tokens: Some(4_096),
2538 input_tokens: Some(3_000),
2539 output_tokens: Some(512),
2540 });
2541
2542 let planned = plan(&config, &identity, false, "refactor the parser").await;
2543 let planned_limits =
2544 crate::route_budget::known_route_limits(planned.route.candidate.limits());
2545 let expected_input_budget = context_input_budget_for_route(
2546 planned.route.identity.provider,
2547 &planned.route.model,
2548 planned_limits,
2549 0,
2550 );
2551 let expected_wire_output = crate::route_budget::effective_max_output_tokens_for_route(
2552 planned.route.identity.provider,
2553 &planned.route.model,
2554 planned_limits,
2555 );
2556 let manifest = engine
2557 .build_request_manifest(inputs(false, Some(planned), "refactor the parser"))
2558 .await;
2559
2560 let route = manifest.route.exact().expect("a fixed route is exact");
2561 assert_eq!(route.provider_id.as_str(), "deepseek");
2562 assert_eq!(route.routing_source, "active-fixed-route");
2563 assert_eq!(route.dialect, "chat-completions");
2564 assert_eq!(route.caller_entrypoint, "streaming");
2565 assert_eq!(route.body_stream_field, Some(true));
2566 assert_eq!(route.context_limit_tokens, 123_456);
2567 assert_eq!(
2568 route.context_limit_source,
2569 crate::route_runtime::ContextWindowSource::Configured
2570 );
2571 assert_eq!(
2572 route.route_input_limit_tokens,
2573 planned_limits.and_then(|limits| limits.input_tokens)
2574 );
2575 assert_eq!(
2576 route.route_output_limit_tokens,
2577 planned_limits.and_then(|limits| limits.output_tokens)
2578 );
2579 assert!(!route.wire_model.is_redacted());
2580 assert!(
2581 manifest.tools.exact().is_some(),
2582 "MCP is off in this engine"
2583 );
2584
2585 let body = manifest.body.exact().expect("a prompted body is exact");
2586 assert_eq!(body.input_budget_ceiling_tokens, expected_input_budget);
2587 assert_eq!(
2588 body.wire_output_cap_tokens,
2589 Some(u64::from(expected_wire_output))
2590 );
2591 assert_eq!(body.body_sha256.len(), 64);
2592 assert!(
2593 body.message_count >= 1,
2594 "the hypothetical prompt is a message"
2595 );
2596 assert!(body.local_system_tools_component_sha256.is_some());
2597 assert!(manifest.session.hypothetical_prompt_supplied);
2598
2599 // The prompt is genuinely part of the request being described.
2600 let other_planned = plan(&config, &identity, false, "write the release notes").await;
2601 let other = engine
2602 .build_request_manifest(inputs(
2603 false,
2604 Some(other_planned),
2605 "write the release notes",
2606 ))
2607 .await;
2608 assert_ne!(
2609 body.body_sha256,
2610 other.body.exact().expect("exact").body_sha256,
2611 "a different next prompt must produce a different body hash"
2612 );
2613 }
2614
2615 /// The engine can describe an Auto route receipt supplied by a trusted
2616 /// host without consulting installed state. The human preview command
2617 /// deliberately never obtains such a receipt, because doing so would call
2618 /// the provider-backed classifier.
2619 #[tokio::test]
2620 async fn host_supplied_auto_route_receipt_matches_the_production_planner() {
2621 let config = deepseek_config();
2622 let identity = deepseek_identity();
2623 let (mut engine, _handle, _tmp) = preview_engine(&config);
2624 engine.config.features.disable(Feature::Mcp);
2625
2626 let planned = plan(&config, &identity, true, "explain this stack trace").await;
2627 let planned_provider = planned.effective_provider;
2628 let planned_identity = planned.effective_provider_identity.clone();
2629 let planned_model = planned.route.model.clone();
2630 let planned_base_url = planned.route.candidate.endpoint().base_url.clone();
2631 assert!(
2632 planned.auto_controls_reasoning,
2633 "the helper requests auto reasoning for its auto-model fixture"
2634 );
2635
2636 let manifest = engine
2637 .build_request_manifest(inputs(true, Some(planned), "explain this stack trace"))
2638 .await;
2639
2640 let route = manifest
2641 .route
2642 .exact()
2643 .expect("auto + prompt resolves a route");
2644 assert_eq!(route.provider_id.as_str(), planned_provider.as_str());
2645 assert_eq!(route.routing_source, "auto-provider-classifier");
2646 assert_eq!(planned_identity, "deepseek");
2647 assert_eq!(
2648 route.wire_model.as_str(),
2649 crate::config::wire_model_for_provider_route(
2650 planned_provider,
2651 &planned_base_url,
2652 &planned_model,
2653 ),
2654 "the wire model is the planner's model after route remapping — not \
2655 the model the session happens to have installed"
2656 );
2657 assert_eq!(
2658 manifest.session.requested_model.as_str(),
2659 "auto",
2660 "the manifest never reports the resolved model as the user's selection"
2661 );
2662
2663 let body = match &manifest.body {
2664 Availability::Exact(body) => body,
2665 Availability::Unavailable(unavailable) => {
2666 panic!("auto + prompt should have an exact body: {unavailable:?}")
2667 }
2668 };
2669 assert_ne!(
2670 body.reasoning_resolution,
2671 ReasoningResolution::Explicit,
2672 "an auto-routed turn never claims an explicit user tier"
2673 );
2674
2675 // The hypothetical prompt is part of the hashed body on the auto path
2676 // too, not only on the fixed one.
2677 let other = plan(&config, &identity, true, "rename one local variable").await;
2678 let other = engine
2679 .build_request_manifest(inputs(true, Some(other), "rename one local variable"))
2680 .await;
2681 assert_ne!(
2682 body.body_sha256,
2683 other.body.exact().expect("exact").body_sha256
2684 );
2685 }
2686
2687 /// The passive path must not create an MCP pool, connect a server, or
2688 /// emit a UI event — it reports the tool surface unavailable instead.
2689 #[tokio::test]
2690 async fn preview_tool_snapshot_has_no_mcp_or_event_side_effects() {
2691 let tmp = tempfile::tempdir().expect("tempdir");
2692 let config = crate::config::Config {
2693 provider: Some("deepseek".to_string()),
2694 ..crate::config::Config::default()
2695 };
2696 let (mut engine, handle) = Engine::new(
2697 EngineConfig {
2698 workspace: tmp.path().to_path_buf(),
2699 ..Default::default()
2700 },
2701 &config,
2702 );
2703 let _ = engine.config.features.enable(Feature::Mcp);
2704
2705 let policy = TurnAuthority::from_effective_fields(
2706 AppMode::Agent,
2707 false,
2708 false,
2709 false,
2710 crate::tui::approval::ApprovalMode::Suggest,
2711 );
2712 let build = engine
2713 .build_turn_tool_registry_and_catalog(
2714 &policy,
2715 &[],
2716 None,
2717 SubAgentWiring::Inert,
2718 McpAccess::PassiveSnapshot,
2719 TurnRouteContext {
2720 provider: engine.api_provider,
2721 model: engine.session.model.clone(),
2722 capabilities: engine.active_route_capabilities,
2723 limits: engine.active_route_limits,
2724 client: engine.deepseek_client.clone(),
2725 api_config: Box::new(engine.api_config.clone()),
2726 locale_tag: engine.config.locale_tag.clone(),
2727 role_models: engine.subagent_role_models(),
2728 fleet_roster: engine.config.fleet_roster.clone(),
2729 auto_model: false,
2730 reasoning_effort: None,
2731 reasoning_effort_auto: false,
2732 },
2733 "",
2734 )
2735 .await;
2736
2737 assert!(
2738 engine.mcp_pool.is_none(),
2739 "a passive snapshot must not create the MCP pool"
2740 );
2741 assert!(
2742 matches!(build.mcp, McpToolState::Unavailable { .. }),
2743 "with no connected pool the MCP tool state is unavailable, not empty"
2744 );
2745 assert!(build.mcp.server_count().is_none());
2746 drop(handle);
2747 }
2748
2749 /// The reviewed blocker: with MCP enabled but nothing connected, the
2750 /// preview built a catalog with zero MCP tools, prepared a body from it,
2751 /// and published that body as `Exact` — a hash of a request no turn would
2752 /// ever send. The body must inherit the tool surface's typed reason.
2753 #[tokio::test]
2754 async fn unavailable_mcp_state_makes_the_body_unavailable_too() {
2755 let config = deepseek_config();
2756 let identity = deepseek_identity();
2757 let (mut engine, _handle, _tmp) = preview_engine(&config);
2758 // MCP on, pool never started: a real turn would connect and could
2759 // discover tools this catalog does not contain.
2760 let _ = engine.config.features.enable(Feature::Mcp);
2761
2762 let planned = plan(&config, &identity, false, "refactor the parser").await;
2763 let manifest = engine
2764 .build_request_manifest(inputs(false, Some(planned), "refactor the parser"))
2765 .await;
2766
2767 assert!(
2768 manifest.tools.exact().is_none(),
2769 "an unconnected MCP pool is not a snapshottable tool surface"
2770 );
2771 assert!(
2772 manifest.body.exact().is_none(),
2773 "a body built from a tool surface missing its MCP contribution \
2774 must not be published as exact"
2775 );
2776 assert!(
2777 manifest.route.exact().is_some(),
2778 "the route does not depend on the MCP contribution and stays exact"
2779 );
2780
2781 // No body fact — hash, byte count, or local component fingerprint —
2782 // reaches either surface.
2783 let json = manifest.to_json();
2784 for forbidden in [
2785 "body_sha256",
2786 "local_system_tools_component_sha256",
2787 "tool_schema_wire_sha256",
2788 "body_canonical_json_bytes",
2789 "estimated_input_headroom_tokens",
2790 ] {
2791 assert!(!json.contains(forbidden), "{forbidden} leaked:\n{json}");
2792 }
2793 assert!(json.contains("mcp-state-not-snapshottable"), "{json}");
2794 assert!(engine.mcp_pool.is_none(), "no pool was created by looking");
2795 }
2796
2797 /// A preview is an inspection. Every piece of engine state a turn would
2798 /// have written must be byte-identical afterwards — including the ones the
2799 /// earlier implementation wrote and restored around an `.await`.
2800 #[tokio::test]
2801 async fn building_a_manifest_writes_no_engine_state() {
2802 let config = deepseek_config();
2803 let identity = deepseek_identity();
2804 let (mut engine, _handle, _tmp) = preview_engine(&config);
2805 engine.config.features.disable(Feature::Mcp);
2806 engine.config.allowed_tools = Some(vec!["Bash".to_string()]);
2807 engine.session.add_message(Message {
2808 role: "user".to_string(),
2809 content: vec![ContentBlock::Text {
2810 text: "an earlier turn".to_string(),
2811 cache_control: None,
2812 }],
2813 });
2814
2815 let allowed_before = engine.config.allowed_tools.clone();
2816 let disallowed_before = engine.config.disallowed_tools.clone();
2817 let messages_before = engine.messages_with_turn_metadata();
2818 let model_before = engine.session.model.clone();
2819 let system_prompt_before = system_prompt_hash(engine.session.system_prompt.as_ref());
2820 let system_hash_before = engine.session.last_system_prompt_hash;
2821 let working_set_before = engine
2822 .session
2823 .working_set
2824 .summary_block(&engine.config.workspace);
2825 let provider_before = engine.api_provider;
2826 let mode_before = engine.current_mode;
2827 let narrowing_before = format!("{:?}", engine.last_policy_narrowing);
2828 let turn_counter_before = engine.turn_counter;
2829
2830 // A *different* command-scoped gate than the installed one, and a
2831 // prompt that mentions a path so the working set would move if the
2832 // preview observed it on the session rather than on a clone.
2833 let mut preview_inputs = inputs(
2834 false,
2835 Some(plan(&config, &identity, false, "inspect src/lib.rs").await),
2836 "inspect src/lib.rs",
2837 );
2838 preview_inputs.allowed_tools = Some(vec!["Read".to_string()]);
2839 let manifest = engine.build_request_manifest(preview_inputs).await;
2840 assert!(manifest.body.exact().is_some(), "fixture should be exact");
2841
2842 assert_eq!(engine.config.allowed_tools, allowed_before, "tool gate");
2843 assert_eq!(engine.config.disallowed_tools, disallowed_before);
2844 assert_eq!(
2845 engine.messages_with_turn_metadata(),
2846 messages_before,
2847 "history"
2848 );
2849 assert_eq!(engine.session.model, model_before);
2850 assert_eq!(
2851 system_prompt_hash(engine.session.system_prompt.as_ref()),
2852 system_prompt_before
2853 );
2854 assert_eq!(engine.session.last_system_prompt_hash, system_hash_before);
2855 assert_eq!(
2856 engine
2857 .session
2858 .working_set
2859 .summary_block(&engine.config.workspace),
2860 working_set_before,
2861 "the hypothetical message is observed on a clone, never on the session"
2862 );
2863 assert_eq!(engine.api_provider, provider_before);
2864 assert_eq!(engine.current_mode, mode_before);
2865 assert_eq!(
2866 format!("{:?}", engine.last_policy_narrowing),
2867 narrowing_before
2868 );
2869 assert_eq!(engine.turn_counter, turn_counter_before);
2870 assert!(engine.mcp_pool.is_none());
2871 }
2872
2873 /// The gate is a parameter, so it shapes the previewed catalog without
2874 /// ever being installed.
2875 #[tokio::test]
2876 async fn the_previewed_tool_gate_applies_without_being_installed() {
2877 let config = deepseek_config();
2878 let identity = deepseek_identity();
2879 let (mut engine, _handle, _tmp) = preview_engine(&config);
2880 engine.config.features.disable(Feature::Mcp);
2881
2882 let wide = engine
2883 .build_request_manifest(inputs(
2884 false,
2885 Some(plan(&config, &identity, false, "do the thing").await),
2886 "do the thing",
2887 ))
2888 .await;
2889
2890 let mut narrow_inputs = inputs(
2891 false,
2892 Some(plan(&config, &identity, false, "do the thing").await),
2893 "do the thing",
2894 );
2895 narrow_inputs.allowed_tools = Some(vec!["Read".to_string()]);
2896 let narrow = engine.build_request_manifest(narrow_inputs).await;
2897
2898 let wide_tools = wide.tools.exact().expect("exact");
2899 let narrow_tools = narrow.tools.exact().expect("exact");
2900 assert!(
2901 narrow_tools.active_tool_count < wide_tools.active_tool_count,
2902 "the passed gate must narrow the previewed catalog: {} vs {}",
2903 narrow_tools.active_tool_count,
2904 wide_tools.active_tool_count
2905 );
2906 assert_eq!(
2907 narrow.session.allowed_tool_gate_count,
2908 Some(1),
2909 "and the session section reports the gate that was previewed"
2910 );
2911 assert_eq!(
2912 engine.config.allowed_tools, None,
2913 "…while the engine keeps its own"
2914 );
2915 }
2916
2917 /// A plan failure still happened *because of* a supplied prompt. Reporting
2918 /// otherwise tells the user to pass the flag they just passed.
2919 #[tokio::test]
2920 async fn a_failed_plan_still_reports_that_a_prompt_was_supplied() {
2921 let (mut engine, _handle, _tmp) = preview_engine(&crate::config::Config::default());
2922 let mut failed = inputs(false, None, "");
2923 failed.unresolved = PreviewUnresolved::PlanFailed(
2924 "no API key configured for route 'my-gateway' at /home/someone/.config".to_string(),
2925 );
2926
2927 let manifest = engine.build_request_manifest(failed).await;
2928 assert!(manifest.session.hypothetical_prompt_supplied);
2929 assert!(manifest.route.exact().is_none());
2930
2931 let rendered = manifest.render();
2932 assert!(
2933 !rendered.contains("Pass `--prompt <text>`"),
2934 "the user already did:\n{rendered}"
2935 );
2936 // …and the raw host text never reaches a surface verbatim.
2937 for surface in [rendered, manifest.to_json()] {
2938 assert!(!surface.contains("my-gateway'"), "{surface}");
2939 assert!(!surface.contains("/home/someone"), "{surface}");
2940 }
2941 }
2942
2943 /// Pending runtime injections are *counted*, never consumed, and they make
2944 /// the body unavailable rather than silently absent from it.
2945 #[tokio::test]
2946 async fn pending_runtime_injections_make_the_body_unavailable_without_consuming_them() {
2947 let config = deepseek_config();
2948 let identity = deepseek_identity();
2949 let (mut engine, _handle, _tmp) = preview_engine(&config);
2950 engine.config.features.disable(Feature::Mcp);
2951 engine.pending_lsp_blocks.push(crate::lsp::DiagnosticBlock {
2952 file: std::path::PathBuf::from("src/lib.rs"),
2953 items: Vec::new(),
2954 });
2955
2956 let manifest = engine
2957 .build_request_manifest(inputs(
2958 false,
2959 Some(plan(&config, &identity, false, "fix it").await),
2960 "fix it",
2961 ))
2962 .await;
2963
2964 assert!(
2965 manifest.body.exact().is_none(),
2966 "the turn loop would inject diagnostics before the first request"
2967 );
2968 assert!(manifest.route.exact().is_some());
2969 assert_eq!(
2970 engine.pending_lsp_blocks.len(),
2971 1,
2972 "inspecting must not flush the pending blocks"
2973 );
2974 assert!(
2975 manifest
2976 .to_json()
2977 .contains("runtime-transforms-before-send"),
2978 "{}",
2979 manifest.to_json()
2980 );
2981 }
2982 }
2983
2983 lines RUST