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