| 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 |