返回 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::compaction::should_compact;
59 use crate::request_manifest::{
60 Availability, BasePromptProvenance, BillingFacts, ManifestDraft, PreparedBodyInputs,
61 PromptProvenance, ReasoningResolution, RequestManifest, RouteFacts, SessionFacts,
62 SystemPromptAssembly, ToolSurfaceFacts, UnavailableReason,
63 };
64 use crate::route_runtime::ResolvedRuntimeRoute;
65 use crate::safe_label::SafeLabel;
66 use codewhale_core::request::{PrimaryTurnRequest, prepare_primary_turn_request};
67
68 /// Everything the host must supply for the engine to describe the next
69 /// request. These are the same posture fields a `SendMessage` would carry, so
70 /// the preview describes the turn the user is actually about to run.
71 #[derive(Debug)]
72 pub struct PreviewRequestInputs {
73 pub mode: AppMode,
74 pub allow_shell: bool,
75 pub trust_mode: bool,
76 pub auto_approve: bool,
77 pub approval_mode: ApprovalMode,
78 pub allowed_tools: Option<Vec<String>>,
79 pub dynamic_tools: Vec<DynamicToolSpec>,
80 pub provenance: UserInputProvenance,
81 /// The model selector the user chose: `auto` when auto model routing is
82 /// on. Never the concrete model an unresolved auto route might pick.
83 pub requested_model: String,
84 /// Reasoning tier the user has selected (`auto`, `high`, `off`, …).
85 pub requested_reasoning: String,
86 pub auto_model: bool,
87 /// Whether the *caller* supplied a hypothetical next prompt.
88 ///
89 /// Deliberately independent of [`Self::next_turn`]: when planning that
90 /// prompt fails, the manifest must still say a prompt was supplied.
91 /// Deriving the flag from `next_turn.is_some()` told the user to "pass
92 /// `--prompt`" when they just had.
93 pub hypothetical_prompt_supplied: bool,
94 /// The exact next turn, resolved by the host's shared route planner.
95 /// `None` means no exact next turn exists to describe.
96 pub next_turn: Option<Box<PreviewNextTurn>>,
97 /// Why `next_turn` is absent. Ignored when `next_turn` is present.
98 pub unresolved: PreviewUnresolved,
99 }
100
101 /// One hypothetical next turn, planned by the production route planner.
102 #[derive(Debug)]
103 pub struct PreviewNextTurn {
104 /// The model-facing text of the hypothetical user message, already
105 /// through the host's file-mention/skill resolution — the same string a
106 /// real `SendMessage` would carry. Never stored in the session and never
107 /// sent to a provider.
108 pub content: String,
109 /// The route the planner resolved for this turn.
110 pub route: Box<ResolvedRuntimeRoute>,
111 /// Immutable prompt facts captured from the same host state as the
112 /// matching production submit.
113 pub prompt_context: NextTurnPromptContext,
114 /// Normalized reasoning-effort api value from the planner, exactly as it
115 /// would be sent.
116 pub reasoning_effort: Option<String>,
117 /// True when the user selected auto reasoning and the planner picked that
118 /// tier.
119 pub reasoning_effort_auto: bool,
120 /// How the auto router chose this route, when auto routing ran.
121 pub auto_route_source: Option<String>,
122 /// Typed selection provenance captured by the shared production planner.
123 pub routing_source: crate::turn_route_plan::TurnRoutingSource,
124 /// The compaction policy the planner resolved for this route. A real turn
125 /// installs it before the turn loop decides whether to auto-compact, so
126 /// the preview evaluates that decision against the same policy.
127 pub compaction: crate::compaction::CompactionConfig,
128 }
129
130 /// Why no exact next turn was planned.
131 #[derive(Debug, Clone)]
132 pub enum PreviewUnresolved {
133 /// Auto model routing is on and no hypothetical prompt was supplied.
134 AutoRouteNeedsPrompt,
135 /// Auto model routing needs a classifier provider call. A preview is
136 /// strictly offline, so it stops before invoking the shared route planner.
137 AutoRouteClassificationNotExecuted,
138 /// No hypothetical prompt was supplied, so there is no next-turn body.
139 NoPrompt,
140 /// The shared planner ran and failed. Carries raw host text; it crosses
141 /// the safe-label boundary before it reaches any surface.
142 PlanFailed(String),
143 /// Mutable `message_submit` hooks are configured. A real submit runs them
144 /// before file mentions, skill wrapping, route planning, and the tool
145 /// policy see the text, and they may replace or block it outright. An
146 /// inspection must not execute a hook, so nothing downstream of the text
147 /// — route, tools, or body — can be claimed exact.
148 MessageSubmitHooksConfigured,
149 /// Resolving the prompt into model-facing content failed exactly as a real
150 /// submit would have failed. Carries raw host text.
151 PromptResolutionFailed(String),
152 }
153
154 impl PreviewUnresolved {
155 fn as_availability<T>(&self) -> Availability<T> {
156 match self {
157 Self::AutoRouteNeedsPrompt => {
158 Availability::unavailable(UnavailableReason::AutoRouteUnresolvedUntilNextPrompt)
159 }
160 Self::AutoRouteClassificationNotExecuted => {
161 Availability::unavailable(UnavailableReason::AutoRouteClassificationNotExecuted)
162 }
163 Self::NoPrompt => {
164 Availability::unavailable(UnavailableReason::NoHypotheticalPromptSupplied)
165 }
166 Self::PlanFailed(error) => {
167 Availability::unavailable_with(UnavailableReason::RoutePlanFailed, error.clone())
168 }
169 Self::MessageSubmitHooksConfigured => {
170 Availability::unavailable(UnavailableReason::MessageSubmitHooksNotExecuted)
171 }
172 Self::PromptResolutionFailed(error) => Availability::unavailable_with(
173 UnavailableReason::PromptResolutionFailed,
174 error.clone(),
175 ),
176 }
177 }
178 }
179
180 impl Engine {
181 /// Describe the request the next turn would send, without sending it.
182 pub(super) async fn build_request_manifest(
183 &mut self,
184 inputs: PreviewRequestInputs,
185 ) -> RequestManifest {
186 let session = self.preview_session_facts(&inputs);
187
188 // Mirror terminal continuation gates before request construction.
189 // Token budgets are telemetry-only in unbounded goal mode, so an
190 // active goal remains previewable after crossing or lowering a budget.
191 let goal_budget_exhausted = match self.config.goal_state.lock() {
192 Ok(state) => {
193 let snapshot = state.snapshot();
194 Ok(snapshot.is_active()
195 && crate::goal_loop::token_budget_exhausted(
196 crate::goal_loop::GoalProgress {
197 tokens_used: snapshot.tokens_used,
198 time_used_seconds: snapshot.time_used_seconds,
199 continuations: snapshot.continuation_count,
200 },
201 crate::goal_loop::GoalBudget {
202 token_budget: snapshot.token_budget.map(u64::from),
203 time_budget_seconds: None,
204 enforce_token_budget: self.config.goal_enforce_token_budget,
205 max_continuations: self.config.goal_max_continuations,
206 },
207 ))
208 }
209 Err(err) => {
210 tracing::warn!("goal state lock poisoned while previewing request: {err}");
211 Err(())
212 }
213 };
214 let unavailable_reason = match goal_budget_exhausted {
215 Ok(true) => Some(UnavailableReason::GoalTokenBudgetExhausted),
216 Ok(false) => None,
217 Err(()) => Some(UnavailableReason::GoalStateNotSnapshottable),
218 };
219 if let Some(reason) = unavailable_reason {
220 return RequestManifest::build(ManifestDraft {
221 session,
222 route: Availability::unavailable(reason),
223 tools: Availability::unavailable(reason),
224 body: Availability::unavailable(reason),
225 });
226 }
227
228 let Some(next_turn) = inputs.next_turn else {
229 let unresolved = inputs.unresolved;
230 return RequestManifest::build(ManifestDraft {
231 session,
232 route: unresolved.as_availability(),
233 tools: unresolved.as_availability(),
234 body: unresolved.as_availability(),
235 });
236 };
237
238 let PreviewNextTurn {
239 content: hypothetical_content,
240 route: planned_route,
241 prompt_context: planned_prompt_context,
242 reasoning_effort,
243 reasoning_effort_auto,
244 auto_route_source,
245 routing_source,
246 compaction: planned_compaction,
247 } = *next_turn;
248
249 // Project the planned route into a throw-away client. `validate`
250 // reuses the host's preflighted client when there is one and never
251 // touches engine state — unlike `install_resolved_runtime_route`,
252 // which is what a real turn calls.
253 let route = match (*planned_route).validate() {
254 Ok(route) => route,
255 Err(error) => {
256 let unavailable = PreviewUnresolved::PlanFailed(error);
257 return RequestManifest::build(ManifestDraft {
258 session,
259 route: unavailable.as_availability(),
260 tools: unavailable.as_availability(),
261 body: unavailable.as_availability(),
262 });
263 }
264 };
265
266 let provider = route.identity.provider;
267 let model = route.model.clone();
268 let limits = crate::route_budget::known_route_limits(route.candidate.limits());
269 let base_url = route.candidate.endpoint().base_url.clone();
270 let route_context = TurnRouteContext {
271 provider,
272 model: model.clone(),
273 capabilities: route.candidate.capabilities(),
274 limits,
275 client: Some(route.client.clone()),
276 api_config: route.config.clone(),
277 locale_tag: self.config.locale_tag.clone(),
278 role_models: self.subagent_role_models(),
279 auto_model: inputs.auto_model,
280 reasoning_effort: reasoning_effort.clone(),
281 reasoning_effort_auto,
282 };
283
284 // Same policy derivation as `handle_send_message`, so the catalog is
285 // filtered under the posture the next turn would actually use.
286 let input_policy = effective_input_policy(
287 inputs.provenance,
288 inputs.mode,
289 &hypothetical_content,
290 inputs.allow_shell,
291 inputs.trust_mode,
292 inputs.auto_approve,
293 inputs.approval_mode,
294 );
295 let prompt_context = NextTurnPromptContext {
296 mode: input_policy.mode,
297 ..planned_prompt_context
298 };
299
300 // The command-scoped allow gate is *passed*, never installed. The
301 // earlier shape wrote `self.config.allowed_tools`, awaited the whole
302 // catalog build, and wrote it back: for the duration of that await the
303 // engine carried a gate belonging to a turn that was never going to
304 // run, and a cancellation or panic in between would have left it
305 // installed for good.
306 let build = self
307 .build_turn_tool_registry_and_catalog(
308 &input_policy,
309 &inputs.dynamic_tools,
310 inputs.allowed_tools.clone(),
311 SubAgentWiring::Inert,
312 McpAccess::PassiveSnapshot,
313 route_context.clone(),
314 "",
315 )
316 .await;
317
318 // The build owns the exact same initial subset dispatch consumes.
319 let surface = &build.surface;
320 let active_tools = surface.active.clone().unwrap_or_default();
321 let active_catalog_sha256 = active_tool_catalog_sha256(&active_tools);
322
323 let tool_choice = surface.active.as_ref().map(|_| {
324 if surface.strict_tool_mode {
325 json!("required")
326 } else {
327 json!({ "type": "auto" })
328 }
329 });
330
331 // The tool surface is only publishable when the MCP contribution is
332 // exactly known. Anything else would be "the tools of some other
333 // turn", which is the failure mode this command exists to avoid.
334 let tools = match build.mcp.server_count() {
335 Some(mcp_server_count) => Availability::Exact(ToolSurfaceFacts {
336 catalog_tool_count: surface.catalog.len(),
337 deferred_tool_count: surface
338 .catalog
339 .iter()
340 .filter(|tool| tool.defer_loading.unwrap_or(false))
341 .count(),
342 active_tool_count: active_tools.len(),
343 active_tool_catalog_sha256: active_catalog_sha256,
344 tool_surface_budget: format!(
345 "{:?}",
346 route_context.capability_profile().tool_surface_budget
347 ),
348 standard_and_full_surfaces_collapsed: standard_and_full_collapse(
349 &surface.catalog,
350 &self.config.tools_always_load,
351 ),
352 mcp_server_count,
353 mcp_tool_count: active_tools
354 .iter()
355 .filter(|tool| build.mcp_tool_names.contains(&tool.name))
356 .count(),
357 }),
358 None => match &build.mcp {
359 McpToolState::Unavailable { reason } => Availability::unavailable_with(
360 UnavailableReason::McpStateNotSnapshottable,
361 reason.label(),
362 ),
363 McpToolState::Disabled | McpToolState::Live { .. } => {
364 Availability::unavailable(UnavailableReason::McpStateNotSnapshottable)
365 }
366 },
367 };
368
369 // The system prompt a turn would send is composed for *its* route, so
370 // an auto-routed preview must not reuse the installed model's prompt.
371 // A session-level override wins here exactly as it does in
372 // `refresh_system_prompt`.
373 // The header is pinned for the session: with unchanged explicit
374 // inputs a real turn reuses the pinned bytes (workspace drift arrives
375 // as a `<context_update>` message instead), so preview mirrors that.
376 let system_prompt = if self.session.system_prompt_override
377 || self.session.pinned_prompt_context.as_ref() == Some(&prompt_context)
378 {
379 self.session.system_prompt.clone()
380 } else {
381 self.compose_stable_system_prompt(&prompt_context)
382 };
383
384 // The hypothetical user message goes through the same constructor
385 // production uses — turn metadata, route stamp, and provenance — so
386 // the body being hashed is the body a real turn would build. It is
387 // appended to a *clone* of the history and discarded: the session
388 // never sees it.
389 //
390 // A real submit calls `working_set.observe_user_message` before it
391 // writes `<turn_meta>`, so the block reflects files the new message
392 // mentions. The preview observes the message on a **clone** of the
393 // working set and builds the block from that snapshot: same bytes, no
394 // session write. Nothing here restores state, because nothing here
395 // changes any.
396 let mut previewed_working_set = self.session.working_set.clone();
397 previewed_working_set.observe_user_message(&hypothetical_content, &self.session.workspace);
398 // #5187: the git-snapshot line is emitted on change only, tracked in a
399 // session cache. Previewing a turn must not advance that cache — the
400 // model never saw the previewed block — so the cache is saved and
401 // restored around the hypothetical build, same as the working set.
402 let previewed_git_snapshot = self
403 .last_turn_meta_git_snapshot
404 .lock()
405 .unwrap_or_else(std::sync::PoisonError::into_inner)
406 .clone();
407 let hypothetical_user_message = self.user_text_message_from_snapshot(
408 hypothetical_content.clone(),
409 &model,
410 inputs.auto_model,
411 reasoning_effort.as_deref(),
412 reasoning_effort_auto,
413 inputs.provenance,
414 TurnMetadataSnapshot {
415 prompt_context: &prompt_context,
416 system_prompt: system_prompt.as_ref(),
417 approval_mode: input_policy.approval_mode_for_session(),
418 working_set: &previewed_working_set,
419 policy_narrowing: input_policy.narrowing.as_ref(),
420 },
421 );
422 *self
423 .last_turn_meta_git_snapshot
424 .lock()
425 .unwrap_or_else(std::sync::PoisonError::into_inner) = previewed_git_snapshot;
426 // Classification input for the provenance section: the prompt this
427 // request actually carries, not the session's current one.
428 let system_prompt_text =
429 codewhale_core::prefix_cache::system_prompt_text(system_prompt.as_ref());
430
431 let mut messages = self.messages_with_turn_metadata();
432 messages.push(hypothetical_user_message);
433
434 // Transforms the turn loop would apply to this conversation between
435 // dispatch and the wire. Detected read-only; nothing pending is
436 // consumed, drained, or flushed by looking.
437 let mut runtime_transforms = self
438 .preview_runtime_transforms(&messages, system_prompt.as_ref(), &planned_compaction)
439 .await;
440
441 // The turn loop resolves an `auto` sentinel tier to its declared
442 // policy value, *after* the planner normalized it. Skipping that step
443 // described a request carrying a literal `auto`, which no route
444 // receives.
445 let effective_reasoning_effort = super::turn_loop::resolve_auto_effort(
446 reasoning_effort.as_deref(),
447 provider,
448 &base_url,
449 &model,
450 );
451
452 // Production sends stored history and nothing else — no synthetic
453 // To-do block, on any step — so the previewed outbound message list is
454 // exactly the message list.
455 let outbound_messages = messages.clone();
456
457 // The production overflow gate estimates the logical messages and
458 // system prompt, not serialized provider-body bytes. Use that same
459 // contract here; the manifest keeps its wire estimate separately as
460 // an observability metric.
461 let production_input_estimate_tokens =
462 crate::compaction::estimate_input_tokens_conservative(
463 &messages,
464 system_prompt.as_ref(),
465 );
466
467 let request = prepare_primary_turn_request(PrimaryTurnRequest {
468 model: model.clone(),
469 messages: outbound_messages,
470 max_tokens: effective_max_output_tokens_for_route(provider, &model, limits),
471 system: system_prompt,
472 tools: surface.active.clone(),
473 tool_choice: tool_choice.clone(),
474 reasoning_effort: effective_reasoning_effort,
475 });
476
477 let prepared = match route.client.prepare_outbound_request(request, true) {
478 Ok(prepared) => prepared.with_route_id(route.identity.exact_id.clone()),
479 Err(error) => {
480 let detail = super::turn_loop::preview_request_error_user_message(
481 &self.config.locale_tag,
482 &error,
483 );
484 // Route identity is read *off the prepared request*, so a
485 // preparation failure leaves the endpoint, wire model, and
486 // dialect unknown too. The tool surface survives: it was built
487 // before the body and does not depend on it.
488 return RequestManifest::build(ManifestDraft {
489 session,
490 route: Availability::unavailable_with(
491 UnavailableReason::RequestPreparationFailed,
492 detail.clone(),
493 ),
494 tools,
495 body: Availability::unavailable_with(
496 UnavailableReason::RequestPreparationFailed,
497 detail,
498 ),
499 });
500 }
501 };
502
503 // `include` on a Responses body discloses reasoning output; it does not
504 // ask the route to think. Treating any control key as a reasoning
505 // request made every Codex turn read as an explicit user selection.
506 let reasoning_resolution = if !prepared.reasoning.controls_reasoning() {
507 ReasoningResolution::NotApplicable
508 } else if reasoning_effort_auto {
509 ReasoningResolution::ResolvedFromHypotheticalPrompt
510 } else if prepared.reasoning.requested_effort.is_none() {
511 ReasoningResolution::RouteDefault
512 } else {
513 ReasoningResolution::Explicit
514 };
515
516 // Headroom and overflow both follow production's message/system
517 // estimator. When an earlier runtime transform cannot be observed
518 // without mutation, `runtime_transforms` makes this body unavailable
519 // rather than publishing a guess.
520 let input_budget_ceiling_tokens =
521 context_input_budget_for_route(provider, &model, limits, 0);
522 if crate::request_manifest::production_input_budget_exceeded(
523 input_budget_ceiling_tokens,
524 production_input_estimate_tokens,
525 ) {
526 runtime_transforms
527 .push("context-overflow recovery would trim or compact the conversation");
528 }
529
530 let route_facts = RouteFacts {
531 provider_id: SafeLabel::identifier(&prepared.endpoint.provider_id),
532 provider_display: SafeLabel::phrase(&prepared.endpoint.provider_display),
533 route_id: prepared
534 .endpoint
535 .route_id
536 .as_deref()
537 .map(SafeLabel::identifier),
538 dialect: prepared.dialect.as_str().to_string(),
539 route_shape: prepared.endpoint.shape.as_str().to_string(),
540 endpoint_host_class: prepared.safe_endpoint_host_class(),
541 endpoint_fingerprint: prepared.endpoint_fingerprint(),
542 wire_model: SafeLabel::catalog_model(&prepared.wire_model),
543 caller_entrypoint: prepared.entrypoint.as_str().to_string(),
544 body_stream_field: prepared.wire_stream_field(),
545 context_limit_tokens: route.context_window.tokens,
546 context_limit_source: route.context_window.source,
547 route_input_limit_tokens: limits.and_then(|limits| limits.input_tokens),
548 route_output_limit_tokens: limits.and_then(|limits| limits.output_tokens),
549 billing: preview_billing_facts(&route.config, provider, &base_url),
550 routing_source: routing_source.label().to_string(),
551 auto_route_source: auto_route_source.as_deref().map(SafeLabel::phrase),
552 };
553
554 let prompt = self.preview_prompt_provenance(&prepared, system_prompt_text.as_str(), &model);
555
556 // The body is a *dependent* fact. A tool surface whose MCP
557 // contribution is unknown does not yield "the same body with no MCP
558 // tools" — a real turn would connect and may send a different tool
559 // list, a different tool region, and therefore a different body,
560 // local component fingerprint, and hash. Publishing an exact body there was the reviewed
561 // defect: it fabricated an empty MCP contribution and hashed it.
562 // Likewise, a request the turn loop would rewrite before sending is
563 // not the request that would be sent.
564 let body = if let Some(inherited) = tools.propagate() {
565 inherited
566 } else if runtime_transforms.is_empty() {
567 Availability::Exact(PreparedBodyInputs {
568 prepared: &prepared,
569 reasoning_resolution,
570 prompt,
571 input_budget_ceiling_tokens,
572 production_input_estimate_tokens,
573 tool_surface_is_exact: true,
574 })
575 } else {
576 Availability::unavailable_with(
577 UnavailableReason::RuntimeTransformsBeforeSend,
578 runtime_transforms.join("; "),
579 )
580 };
581
582 RequestManifest::build(ManifestDraft {
583 session,
584 route: Availability::Exact(route_facts),
585 tools,
586 body,
587 })
588 }
589
590 /// Transforms the turn loop would apply to this conversation between
591 /// dispatch and the first provider request.
592 ///
593 /// Every check is **read-only**. Nothing here drains the steer channel,
594 /// receives a queued sub-agent completion, flushes an LSP block, or runs
595 /// compaction: an inspection that consumed pending state would change the
596 /// very turn it claims to describe. Where a queue can only be *counted*
597 /// rather than inspected, counting is what happens.
598 ///
599 /// Returned strings are compile-time constants. They are joined into a
600 /// typed unavailable detail, which still crosses the safe-label boundary.
601 async fn preview_runtime_transforms(
602 &self,
603 messages: &[Message],
604 system_prompt: Option<&SystemPrompt>,
605 compaction: &crate::compaction::CompactionConfig,
606 ) -> Vec<&'static str> {
607 let mut reasons = Vec::new();
608
609 if !self.pending_lsp_blocks.is_empty() {
610 reasons.push("pending LSP diagnostics would be injected as a synthetic message");
611 }
612
613 let shell_completion_may_be_injected = self.shell_manager.lock().map_or(true, |manager| {
614 manager.may_have_undelivered_completion_for_session(&self.session.id)
615 });
616 if shell_completion_may_be_injected {
617 reasons.push("a background shell completion may be injected before the request");
618 }
619
620 let queued_completions = !self.rx_subagent_completion.is_empty() || {
621 let manager = self.subagent_manager.read().await;
622 manager.may_transform_next_parent_request_for_session(
623 &self.session.id,
624 &self.delivered_subagent_completion_ids,
625 )
626 };
627 if queued_completions {
628 reasons.push("a running or undelivered sub-agent completion may be injected");
629 }
630
631 if crate::compaction::compaction_pressure_reached(messages, system_prompt, compaction) {
632 let prepared = self.prepare_compaction_envelope(compaction.clone());
633 if should_compact(messages, system_prompt, &prepared) {
634 reasons.push("auto-compaction would rewrite the conversation first");
635 }
636 }
637
638 reasons
639 }
640
641 /// Posture that depends on neither the route nor the next message.
642 fn preview_session_facts(&self, inputs: &PreviewRequestInputs) -> SessionFacts {
643 let base = crate::prompts::effective_base_prompt_text();
644 let input_policy = effective_input_policy(
645 inputs.provenance,
646 inputs.mode,
647 "",
648 inputs.allow_shell,
649 inputs.trust_mode,
650 inputs.auto_approve,
651 inputs.approval_mode,
652 );
653 SessionFacts {
654 agent_role: "primary".to_string(),
655 lane_kind: "interactive-primary".to_string(),
656 fleet_assignment: "not-applicable-primary-agent".to_string(),
657 requested_model: SafeLabel::catalog_model(&inputs.requested_model),
658 auto_model_routing: inputs.auto_model,
659 requested_reasoning: SafeLabel::identifier(&inputs.requested_reasoning),
660 // What the caller supplied, not what planning managed to do with
661 // it: a plan failure must not read as "you forgot `--prompt`".
662 hypothetical_prompt_supplied: inputs.hypothetical_prompt_supplied,
663 mode: input_policy.mode.label().to_string(),
664 approval_mode: format!("{:?}", input_policy.approval_mode_for_session()),
665 allowed_tool_gate_count: inputs.allowed_tools.as_ref().map(Vec::len),
666 disallowed_tool_gate_count: self.config.disallowed_tools.as_ref().map(Vec::len),
667 base_prompt: BasePromptProvenance {
668 origin: crate::prompts::base_prompt_origin().label().to_string(),
669 bytes: base.len(),
670 sha256: crate::hashing::sha256_hex(base.as_bytes()),
671 },
672 }
673 }
674
675 /// System-prompt provenance, as labels and hashes only.
676 ///
677 /// `effective` is the prompt of the request being described, so an
678 /// auto-routed preview classifies the prompt it would actually send
679 /// rather than the session's currently installed one.
680 fn preview_prompt_provenance(
681 &self,
682 prepared: &PreparedOutboundRequest,
683 effective: &str,
684 model: &str,
685 ) -> PromptProvenance {
686 let base = crate::prompts::effective_base_prompt_text();
687 let configured =
688 crate::prompts::compose_default_static_layers(crate::prompts::Personality::Calm, model);
689
690 let assembly = if effective.trim().is_empty() {
691 SystemPromptAssembly::None
692 } else if effective.trim() == base.trim() {
693 SystemPromptAssembly::BaseOnly
694 } else if effective.trim() == configured.trim() {
695 SystemPromptAssembly::BaseWithConfiguredLayers
696 } else {
697 SystemPromptAssembly::BaseWithRuntimeAdditions
698 };
699
700 let view = prepared.wire_view();
701 PromptProvenance {
702 assembly,
703 // The hash of the prompt the *prepared request* carries, in its
704 // final wire form — not of an independently recomposed string.
705 effective_system_canonical_json_bytes: view.system_bytes,
706 effective_system_sha256: view.system_sha256.clone(),
707 }
708 }
709 }
710
711 /// Typed billing facts for the planned route, from the same helper the footer
712 /// and sidebar read. Every label is a compile-time constant.
713 fn preview_billing_facts(
714 config: &crate::config::Config,
715 provider: crate::config::ApiProvider,
716 base_url: &str,
717 ) -> BillingFacts {
718 if let Some(surface) = crate::pricing::billing_surface_for_route(provider, Some(base_url)) {
719 return BillingFacts::Surface { surface };
720 }
721 match crate::route_billing::for_route(config, provider) {
722 crate::route_billing::BillingPresentation::Metered => BillingFacts::Metered,
723 crate::route_billing::BillingPresentation::Subscription(plan) => {
724 BillingFacts::Subscription { plan }
725 }
726 crate::route_billing::BillingPresentation::Local => BillingFacts::Local,
727 crate::route_billing::BillingPresentation::Unknown => BillingFacts::Unknown,
728 }
729 }
730
731 /// Stable hash over the exact active tool catalog: name, description, and
732 /// schema, in catalog order. Changes when a tool is added, removed,
733 /// reordered, or has its schema transformed.
734 ///
735 /// This is the *single* definition of the active-tool-catalog digest. The
736 /// request manifest fills `ToolSurfaceFacts::active_tool_catalog_sha256` from
737 /// it, and `crate::tool_inspection` reports the same value for the same
738 /// prepared request. Neither surface keeps a digest of its own, so a human
739 /// reading `/tools` and a human reading `/request` are looking at the same
740 /// accounting object rather than two hashes that can silently diverge.
741 pub(crate) fn active_tool_catalog_sha256(tools: &[Tool]) -> String {
742 let mut canonical = String::new();
743 for tool in tools {
744 canonical.push_str(&tool.name);
745 canonical.push('\u{1}');
746 canonical.push_str(&tool.description);
747 canonical.push('\u{1}');
748 canonical.push_str(&crate::client::canonical_json(&tool.input_schema));
749 canonical.push('\n');
750 }
751 crate::hashing::sha256_hex(canonical.as_bytes())
752 }
753
754 /// Whether the Standard and Full tool surfaces currently produce the same
755 /// catalog.
756 ///
757 /// Derived, not asserted: the surface shaper is run over this exact catalog
758 /// under both budgets and the results compared. If Standard and Full ever
759 /// genuinely diverge, this reports `false` without anyone editing copy.
760 fn standard_and_full_collapse(
761 catalog: &[Tool],
762 always_load: &std::collections::HashSet<String>,
763 ) -> bool {
764 super::tool_catalog::surface_budgets_produce_same_catalog(
765 catalog,
766 always_load,
767 crate::model_profile::ToolSurfaceBudget::Standard,
768 crate::model_profile::ToolSurfaceBudget::Full,
769 )
770 }
771
772 #[cfg(test)]
773 #[path = "preview/tests.rs"]
774 mod tests;
775
775 lines RUST