返回 CodeWhale
PREVIEW_REQUEST.md
根目录 / docs / PREVIEW_REQUEST.md
1 # `/preview-request` — see the outbound request without sending it
2
3 `/preview-request` renders a **request manifest**: a typed, redacted
4 description of the request the next **primary agent turn** would send. It never
5 sends that request, never appends to the conversation, and never writes engine,
6 session, or Work state. `/dryrun` and `/preview_request` are compatibility
7 aliases.
8
9 It is a human command. There is deliberately no model-visible tool for it.
10
11 ```
12 /preview-request # session facts; route/body reported unavailable
13 /preview-request json # same manifest as JSON
14 /preview-request --prompt <text> # the next turn for that prompt
15 /preview-request json --prompt <text> # both
16 /preview-request base-prompt # exact base layer only; no runtime additions
17 ```
18
19 ### Argument grammar
20
21 ```
22 args := flag* [ "--prompt" <whitespace> prompt ]
23 flag := json | --json | manifest | --manifest | prompt | base-prompt | --base-prompt
24 prompt := every remaining byte, verbatim
25 ```
26
27 **Flags come before `--prompt`, which is terminal.** Everything after it is
28 prompt text — including a trailing `json`. `/preview-request --prompt fix it
29 json` previews the prompt *"fix it json"* as a human table; the JSON spelling
30 is `/preview-request json --prompt fix it`. There is exactly one reading of any
31 input, and an unknown argument before `--prompt` is rejected rather than
32 guessed at.
33
34 **The prompt keeps your bytes.** Repeated whitespace and newlines inside it
35 survive; exactly one whitespace codepoint separates `--prompt` from its text
36 and is consumed as syntax. Any additional leading whitespace, plus all
37 trailing whitespace and newlines, remains prompt data. The prompt is hashed
38 into the previewed body, so normalizing it would describe a request that
39 differs from the real one in the one field you typed.
40
41 `prompt` remains a compatibility alias for the ordinary protected manifest.
42 `base-prompt` / `--base-prompt` are an explicit, human-only disclosure mode:
43 they print exactly the effective base-prompt bytes and nothing else. They
44 cannot be combined with JSON or `--prompt`. Effective system text is never
45 printed because it can contain project instructions, skills, and memory.
46
47 ## `--prompt` is necessary for an exact manifest, and not always sufficient
48
49 The next user message is **part of the request**. Without it there is no
50 next-turn body to describe, and under auto model routing there is not even a
51 route: the route is decided by the text you have not typed yet.
52
53 So the manifest is sectioned, and each section is either exact or typed-absent:
54
55 | Section | Exact when |
56 | --- | --- |
57 | `session` | always — posture, gates, base-prompt provenance, requested model/reasoning |
58 | `route` | `--prompt` was supplied, an active goal has not exhausted its token budget, a fixed route is selected, no `message_submit` hooks are configured, and the shared planner resolved that route |
59 | `tools` | the route is exact **and** the MCP tool state can be snapshotted without connecting |
60 | `body` | the tool surface and authoritative Work snapshot are exact **and** no runtime transform would rewrite the request first |
61
62 ### Why a section stops being exact
63
64 | Typed reason | What a real turn would do that an inspection may not |
65 | --- | --- |
66 | `auto-route-unresolved-until-next-prompt` | decide the route from text you have not typed |
67 | `auto-route-classification-not-executed` | call the provider-backed Auto classifier; preview is strictly offline, so production must resolve it |
68 | `no-hypothetical-prompt-supplied` | send a message the manifest does not have |
69 | `message-submit-hooks-not-executed` | run mutable hooks that can rewrite or block the text — and therefore the route, tool policy, and body derived from it |
70 | `prompt-resolution-failed` | fail the same way on skill authority or file mentions |
71 | `route-plan-failed` | fail route resolution or preflight |
72 | `mcp-state-not-snapshottable` | connect MCP servers and discover tools this catalog does not contain |
73 | `runtime-transforms-before-send` | auto-compact, run context-overflow recovery, inject a background-shell completion, allow a running or terminal-undelivered child to complete, or flush pending LSP diagnostics before the first request |
74 | `work-state-not-snapshottable` | read the current graph-backed Work projection; preview never substitutes an asynchronously published, potentially stale To-do view |
75 | `goal-token-budget-exhausted` | stop the active goal before dispatch because durable token usage has reached the configured budget |
76 | `goal-state-not-snapshottable` | decide whether the active goal's terminal budget gate permits another request |
77 | `request-preparation-failed` | build no body at all |
78
79 **`mcp-state-not-snapshottable` takes the body with it.** A catalog missing its
80 MCP contribution is not "the same request with no MCP tools" — a real turn
81 connects, and may send a different tool list, a different tool region, and
82 therefore a different body and hashes. The body inherits the tool
83 section's reason rather than publishing an exact hash of a request that would
84 never be sent. The `route` section survives: the endpoint, dialect, and wire
85 model do not depend on which tools are on the request.
86
87 **Detection is read-only.** Nothing is drained, received, flushed, or
88 compacted to find out: eligible running and terminal-undelivered children are checked, LSP blocks
89 are checked for emptiness, the shell manager is checked without polling or
90 marking completions reported, and the compaction decision is evaluated against
91 the borrowed hypothetical message list with its active slop gate pinned.
92 Inspecting the pending state does not consume it.
93
94 Without `--prompt` the route section is unavailable even on a fixed model.
95 That is deliberate: a route is only reported when it was resolved by the same
96 planner that would send the turn, for the same next message. A route that was
97 "probably still the current one" is exactly the kind of almost-true fact this
98 command exists to avoid.
99
100 An unavailable section publishes a typed reason and **no fields**. When auto
101 routing is unresolved there is no `provider_id`, `route_id`, `dialect`,
102 `endpoint_host_class`, `endpoint_fingerprint`, `wire_model`, `billing`,
103 `tool_surface_budget`, or `body_sha256` anywhere in the JSON — not a `null`,
104 not the previous turn's value. `requested_model` reads `auto`, because that is
105 what you actually selected.
106
107 ## What runs, and what does not
108
109 With `--prompt`, the preview executes the **deterministic part of the
110 production path** up to (but not including) the send:
111
112 1. The prompt is resolved into model-facing content exactly as a real submit
113 does — the pending active skill it would be wrapped with (**cloned**, not
114 consumed), file mentions, git mentions, and the paused-command note — with
115 the same error propagation. What a submit does that an inspection may not is
116 run `message_submit` hooks; when any are configured the manifest says so and
117 claims nothing downstream of the text.
118 2. For a fixed route, that content goes through the **same shared route
119 planner** (`plan_turn_route`) that `spawned_dispatch_inner` uses for a real
120 turn: effective provider and model, route identity resolution, preflight,
121 route limits, compaction policy, and reasoning-effort normalization. Auto
122 stops before this step because the planner would call a model classifier.
123 3. The engine projects the planned route into a throw-away client — the same
124 client construction a turn installs, without installing it.
125 4. It rebuilds the tool catalog and narrows it with the same planner the turn
126 loop uses, composes the system prompt **for that route's model and context
127 window**, and appends the hypothetical user message through the same
128 constructor production uses (turn metadata, route stamp, provenance, and the
129 slop-ledger gate included), then resolves an `auto` reasoning tier against
130 those messages the way the turn loop does. It snapshots the authoritative
131 Work/To-do projection read-only and appends that same transient tail to the
132 prepared provider body. Preflight matches production's deliberately
133 conservative decomposition: stored messages plus system are estimated
134 first, then the Work tail is estimated as its own separately framed list.
135 If graph authority cannot be read, the body is unavailable rather than
136 rebuilt from a potentially stale legacy projection.
137 5. It prepares the request through `DeepSeekClient::prepare_outbound_request`
138 and describes the result — unless a runtime transform would rewrite it
139 first, in which case the body is typed unavailable instead.
140
141 **Nothing is installed, not even briefly.** Everything a turn installs before
142 building its request — the command-scoped tool gate, the effective mode and
143 approval posture, the policy-narrowing event, the working set with the new
144 message observed — is passed as a value or snapshotted onto a clone. There is
145 no write-then-restore: a restore is not atomic across an `await`, and it does
146 not survive a cancellation or a panic. The slop-ledger completion gate is read
147 through a variant that does not write its memoization cache. A regression test
148 asserts that config, caches, session messages, model, system prompt, working
149 set, provider, mode, and the MCP pool are all byte-identical afterwards.
150
151 **No outbound call can happen.** With a fixed model, planning and request
152 preparation are local. With Auto selected, the command reports
153 `auto-route-classification-not-executed` and stops before the shared planner,
154 because resolving the route would call a model classifier. Preview never
155 reads or populates the classifier response cache and never changes provider
156 retry or rate-limit state.
157
158 **Nothing else has a side effect.** The tool-catalog build runs in a passive
159 mode: it never creates the MCP pool, calls `connect_all`, reloads an MCP config
160 source, starts a server, spawns a sub-agent runtime task, captures a fork
161 snapshot, or emits a UI status event. When the connected MCP state is not
162 already exactly what a turn would use — no pool yet, a config source changed,
163 or an enabled server is not connected — the `tools` section reports
164 `mcp-state-not-snapshottable` instead of connecting to find out.
165
166 ## Scope: primary agent turns only
167
168 This manifest describes `LlmClient::create_message` /
169 `create_message_stream` — the model turns the agent loop runs. It does **not**
170 describe Codewhale's auxiliary provider calls, which have their own shapes:
171
172 | Auxiliary call | Status |
173 | --- | --- |
174 | Chat-dialect translation (`translate`) | **Not** on the prepared seam: it builds a small fixed body (no tools, temperature 0.1) directly. Out of scope. |
175 | Anthropic/Responses-dialect translation | Routed through `prepare_outbound_request` to avoid a second builder, but still an auxiliary call and still out of scope for this manifest. |
176 | FIM completion, speech, provider-native search, `/models` listing | Separate endpoints and bodies. Out of scope. |
177 | Auto-router classifier | A separate small turn on the router route. Out of scope and never executed by preview. |
178
179 Any claim that "every outbound request" goes through the previewed seam would
180 be false, and this document does not make one.
181
182 ## Where the numbers come from
183
184 **The prepared outbound request.** Every primary model turn reaches the wire
185 through `DeepSeekClient::prepare_outbound_request`, which returns a
186 `PreparedOutboundRequest`: dialect, endpoint identity, canonical wire model,
187 the final body, and a reasoning receipt. Production dispatch sends that value;
188 the preview describes it. There is no second body builder.
189
190 Parity tests do not feed a captured logical request back through that builder.
191 They run a real production turn against an HTTP mock, parse the first body the
192 server actually received, canonicalize those captured bytes independently,
193 and compare that hash with preview. The coverage includes translated prompt
194 context, paused-command detach, and native Anthropic Messages shaping.
195
196 Preparation runs the full production sequence: tool-history repair and
197 model-bound secret redaction, protocol binding and route model re-resolution,
198 the dialect's own body builder with every provider-specific sanitizer and
199 reasoning shaper, and exact endpoint resolution.
200
201 Every production dialect is preserved end to end — nothing is projected
202 through Chat Completions:
203
204 | Dialect | Routes |
205 | --- | --- |
206 | `chat-completions` | DeepSeek, Moonshot/Kimi (including the Kimi Code K3 nested `thinking.effort` shape and the direct K3 fixed-sampling shape), Z.ai, xAI, OpenRouter, vLLM/Ollama/SGLang, OpenCode Zen chat routes, custom compatible endpoints |
207 | `anthropic-messages` | Anthropic, DeepSeek Messages, MiniMax Messages, OpenModel |
208 | `openai-responses` | OpenAI Codex (ChatGPT backend path), OpenCode Zen responses routes |
209
210 The manifest reports the dialect *and* the route shape
211 (`standard`, `deepseek-beta-strict-tools`, `kimi-code-k3`,
212 `direct-moonshot-k3`, `codex-responses`, `opencode-zen`,
213 `custom-compatible`), so you can see which builder branch actually ran.
214
215 **The engine.** The manifest is built by the engine, not the command layer,
216 because only the engine can rebuild the exact next-turn tool catalog, active
217 subset, gates, permission posture, and tool choice. The session's last tool
218 catalog is never consulted — it is one turn stale and stores the
219 pre-activation catalog.
220
221 ## Streaming is reported as a wire fact
222
223 `caller_entrypoint` says which transport entry point was described
224 (`streaming` / `blocking`). `body_stream_field` says what the **body** says,
225 read off the finished JSON:
226
227 - Chat Completions streaming → `true`; Chat blocking → the field is absent
228 (`null`), because the blocking body never carries it.
229 - Anthropic Messages → mirrors the caller.
230 - OpenAI Responses → **always `true`, including on the blocking entry point**,
231 which opens an SSE stream and folds it into one response.
232
233 The manifest describes the body field exactly rather than inferring it from the
234 caller, so the Responses blocking case cannot be misreported as a non-streaming
235 request.
236
237 `tool_choice` is likewise read from the finished provider body, not the
238 logical request: Anthropic may carry an object, Responses carries its mapped
239 string, and DeepSeek thinking requests omit the field entirely.
240
241 ## What the manifest tells you
242
243 | Section | Fields |
244 | --- | --- |
245 | `session` | exact primary-agent role/lane/Fleet non-assignment, requested model (`auto` when auto), routing mode, requested reasoning, whether a hypothetical prompt was supplied, mode, approval posture, allow/deny gate sizes, base-prompt origin + bytes + SHA-256 |
246 | `route` | provider id + display name, named route id, typed routing source, dialect, route shape, safe endpoint host class/digest, endpoint fingerprint, **wire model**, caller entrypoint, body `stream` field, context ceiling + source (`configured`, `provider-reported`, static floor, catalog, or fallback), route input/output limits or `unknown`, typed billing |
247 | `tools` | active count, catalog / deferred counts, logical catalog SHA-256, surface budget, Standard-vs-Full collapse, MCP servers and MCP tools |
248 | `body` | reasoning resolution + wire control keys + wire effort **and the key path it came from**, `tool_choice`, system-prompt assembly + effective canonical JSON bytes/SHA-256, body/system/tool-schema/message/tool-result/attachment/framing canonical JSON sizes, per-class estimates, exact input-budget ceiling and headroom, literal wire output cap or `unknown`, provider-reported usage explicitly unavailable because no request ran, **whole-body SHA-256**, wire tool-schema SHA-256, local system/tools component SHA-256 |
249
250 Counts and estimates are extracted **dialect-aware**: a Responses body's
251 `instructions`/`input`, an Anthropic body's `system`/`messages`, and a Chat
252 body's inline `system`-role message are each read from where that dialect
253 actually keeps them.
254
255 ### The byte classes are an exact accounting, not byte slices
256
257 `system + tool_schemas + messages + framing == body_canonical_json_bytes`, exactly, in
258 every dialect and on both entry points. The first three are the canonical
259 serializations of selected JSON values; they are not four disjoint ranges
260 borrowed from the body buffer. **`framing` is the algebraic remainder** after
261 those selected values. It includes every other top-level field and whatever
262 JSON structure was not already counted inside a selected array value. An
263 invariant test asserts the sum identity, and mutation tests check the intended
264 attribution. Do not use these counts to reconstruct the request bytes.
265
266 `tool_result` and `attachment` bytes are *subsets* of the message bytes,
267 reported for attribution and never added again.
268
269 ### Headroom is measured against the input budget
270
271 `estimated_input_headroom_tokens` subtracts production's conservative
272 messages-plus-system estimate from this route's
273 **input budget ceiling** — `context_input_budget_for_route`, the same seam the
274 turn loop checks before it sends, which is the context window minus the output
275 reservation and the safety headroom. It is not the raw `context_limit_tokens`:
276 subtracting input from a window the route also has to fit its response into
277 reports headroom the turn does not have. The value goes negative when the
278 request would not fit, rather than clamping to zero and reading as "it fits" —
279 and when it does, the body is reported unavailable, because the turn loop would
280 run context-overflow recovery and send something else.
281
282 This production gate is distinct from the manifest's independent conservative
283 estimate over canonical JSON body bytes. The latter remains useful for
284 provider-body attribution, but it does not decide overflow or headroom.
285
286 The manifest publishes that exact ceiling as `input_budget_ceiling_tokens`.
287 It also keeps three different limit facts separate: the context ceiling and
288 its resolver source, optional route/offering input and output limits, and the
289 output cap literally serialized on the wire. If a route or dialect supplies
290 no such fact, the value is `unknown`; preview never fabricates one from a
291 neighboring model or an installed route.
292
293 ### Reasoning controls are read wherever the dialect puts them
294
295 `reasoning_wire_effort` reads the flat `reasoning_effort`, Kimi Code's nested
296 `thinking.effort`, the Responses `reasoning.effort`, and the Anthropic
297 `output_config.effort`, and `reasoning_wire_effort_source` names which one it
298 came from (a compile-time constant, never a key lifted out of the body).
299 Reporting only the flat key made the routes that think hardest read as "no
300 effort sent".
301
302 `reasoning_resolution` distinguishes an `explicit` user selection from a
303 `route-default` control the user never asked for, and reports
304 `not-applicable` when the body asks for no reasoning at all. A Responses
305 `include` field *discloses* reasoning output rather than requesting a tier, so
306 `include` alone is never reported as a reasoning request.
307
308 ## The whole-body hash
309
310 `body_sha256` covers the complete canonicalized wire body, not a prefix. It
311 changes when any of these change: max-token fields, `tool_choice`, nested
312 reasoning controls, provider-transformed tool schemas, attachments, stream
313 options, sampling parameters, or any message — including the appended
314 hypothetical prompt. Canonicalization sorts object keys, so builder insertion
315 order alone does not move the hash. Any real input change does, including
316 date/working-set/git metadata, runtime injections, tool discovery, prompt
317 settings, or routing.
318
319 `tools.active_tool_catalog_sha256` is a separate, stable hash over the current
320 active tool catalog **before dialect shaping** (name, description, canonical
321 logical schema, in order). It moves on membership, ordering, and logical schema
322 changes. It is a catalog identity, not a wire fact: two routes can agree here
323 and still send different bytes, because each dialect transforms schemas its own
324 way and strict mode sanitizes them further.
325
326 `body.tool_schema_wire_sha256` is the hash of the `tools` region **as the
327 provider receives it**. `body.local_system_tools_component_sha256` combines
328 that digest with the final wire system-region digest as a local comparison
329 fingerprint. It is not a provider cache key, does not claim those regions are
330 adjacent, and carries no route-specific cache-semantics guarantee. It is
331 omitted when the tool surface is not exactly known.
332
333 ## Disclosure boundary
334
335 The manifest is a fixed set of counts, hashes, enums, and short provenance
336 labels. It has no field that can hold free-form request text. It cannot
337 contain:
338
339 - the system prompt, project instructions, memory, or skill content;
340 - message content, tool-result bodies, or attachment payloads;
341 - credentials, `Authorization` headers, or query strings;
342 - URL paths (which can themselves carry a deployment secret);
343 - absolute workspace or home paths.
344
345 **Identifiers are not trusted either.** A custom route id and a model id are
346 user-authored text that can be an absolute path, a URL, a URL path, or a
347 deployment id that is itself a credential. Every such value crosses an
348 allowlist boundary (`crate::safe_label`) before it is printed: an
349 generic identifier is published verbatim only when it contains no slash. A
350 slash-bearing model id additionally has to match an exact entry in the active
351 local model catalog; a vendor-looking prefix is not sufficient. Anything else
352 is replaced by a stable `sha256:<12 hex>` fingerprint. Two previews of the same
353 hostile id still compare equal; the id itself is never shown.
354
355 **Error text is not trusted either, and is not scrubbed — it is
356 allowlisted.** Preflight, MCP, prompt-resolution, and request-preparation
357 failures all interpolate host text, and that text routinely carries a route id,
358 a server name in quotes, an endpoint whose *path* is the secret, or a raw
359 credential. Every whitespace-separated token has to earn its place:
360
361 - a token containing a control character is dropped;
362 - a URL keeps only `scheme://host[:port]`, and only when both are themselves
363 ordinary — path, query, fragment, and userinfo are never published, and a
364 token-shaped "host" makes the whole token opaque rather than half-published;
365 - anything path-shaped (POSIX absolute, `~/`, Windows drive, or containing a
366 backslash) collapses to `<path-redacted>`;
367 - a token carrying `"`, `'`, or a backtick is replaced wholesale — quoted spans
368 are where hostile identifiers hide;
369 - everything else must be a short ordinary word (ASCII alphanumerics plus
370 `-`, `_`, `.`, bounded in length, rejected if it looks token- or key-shaped),
371 with only sentence punctuation allowed at its edges.
372
373 Anything else becomes `<redacted>`, runs of redactions collapse, and the result
374 is truncated. Ordinary diagnostic sentences survive intact; hostile ones become
375 a generic shape.
376
377 Endpoints are published two ways only: a bounded host class (`http loopback`
378 or `https remote sha256:<12 hex>`) and a SHA-256 fingerprint of the full URL
379 for "same endpoint?" comparisons. A remote authority is always digested — it
380 may be a credential-shaped tenant subdomain — and paths, IDNs, userinfo,
381 queries, and fragments are never shown.
382
383 **This is an inspectability slice, not a request-body export.** A regression
384 test asserts that no manifest field carries a serialized message array or tool
385 schema.
386
387 ## Estimates are estimates
388
389 Every token number is an **offline estimate** (~4 bytes/token plus a 5%
390 conservative margin), never a provider-authoritative count. Use them to
391 compare requests to each other, not to predict a bill. Byte counts, hashes,
392 and counts are exact.
393
394 Tool-result and attachment estimates are *subsets* of the message estimate and
395 are reported for attribution; they are not added again into the total.
396
397 ## Base-prompt provenance (#3928)
398
399 The protected manifest distinguishes three things without printing effective
400 system text. The explicit `/preview-request base-prompt` mode separately prints
401 only the exact effective base-prompt bytes:
402
403 - **Origin** — where the base-prompt bytes came from:
404 - `bundled in this codewhale-tui build (BASE_PROMPT, compiled in)`
405 - `config-directory override installed at startup (prompts/constitution.md,
406 opt-in enabled)`
407 - **Assembly** — how the effective prompt was built on top of that base:
408 `base prompt only`, `base prompt + configured static layers`, or
409 `base prompt + configured layers + runtime/session additions`.
410 - **Effective hash** — the SHA-256 of the system region of the *prepared
411 request*: the prompt in its final wire form, not an independently
412 recomposed string.
413
414 In a live session the assembly is normally
415 `base prompt + configured layers + runtime/session additions`, because the
416 environment block, project context, skills, and memory are appended after the
417 constitution. Codewhale does not claim the configured constitution is the
418 effective base prompt, and no diagnostic cites a source-tree path that does not
419 exist on an installed binary.
420
421 ## Tool-surface labels
422
423 `standard_and_full_surfaces_collapsed` is **derived, not asserted**: the
424 surface shaper is run over the actual current catalog under both the Standard
425 and the Full budget and the results are compared. Today it reports `true`
426 — the two budgets produce the same catalog — and the manifest says so in
427 plain words rather than implying a difference. The day the shaper narrows
428 Standard differently from Full, the field flips on its own with no copy edit.
429
430 Any benchmark claim about one surface offering "more tools" has to show a
431 different `active_tool_catalog_sha256` first.
432
433 ## Comparing routes before you spend anything
434
435 `/preview-request` makes a provider-free fixed-route A/B possible: switch route,
436 preview with the same prompt, compare.
437
438 1. Select the route (`/model`, `/provider`, or your profile) — do not send a
439 turn.
440 2. Run `/preview-request json --prompt "<the same text each time>"` and save
441 the output, e.g. `glm-5.2.json`.
442 3. Repeat for each route.
443 4. Diff the manifests.
444
445 What to read in the diff:
446
447 - **`route.dialect` / `route.route_shape`** — two routes on different dialects
448 are sending structurally different requests, not the same request to a
449 different host.
450 - **`route.wire_model`** — the id that actually goes on the wire. Router
451 entries frequently differ from the label you selected.
452 - **`tools.active_tool_count` / `body.tool_schema_wire_sha256`** — identical
453 wire hashes mean identical tool bytes, whatever the surface label says. Use
454 `tools.active_tool_catalog_sha256` only to compare *logical* catalogs; two
455 dialects can agree there and still send different schemas.
456 - **`body.estimates.tool_schemas` vs `route.context_limit_tokens`** — the fixed
457 overhead each route pays before the conversation starts, against the window
458 it has.
459 - **`body.reasoning_wire_control_keys` / `body.reasoning_wire_effort` /
460 `body.reasoning_resolution`** — whether the route is actually being asked to
461 think, in which dialect, and whether that came from you or from auto
462 routing. Two routes with different effective effort are not comparable.
463 - **`body.body_sha256`** — the whole request. If it is unchanged, nothing about
464 the outbound bytes changed.
465 - **`body.local_system_tools_component_sha256`** — whether the two locally
466 measured wire components changed. It does not prove provider cache reuse or
467 invalidation.
468 - **`route.billing`** — subscription quota and metered API routes are not
469 cost-comparable, and `unknown` means cost reporting fails closed.
470
471 Each manifest is exact for the snapshot it describes. Repeated previews are
472 identical only when every contributing input is identical: route, current
473 date, git and working-set metadata, prompt settings, tool/MCP
474 state, session history, and pending runtime transforms. Previewing does not
475 mutate session history or the response cache, but no cross-call byte-stability
476 claim is made. Auto is intentionally unavailable and cannot be used for this
477 comparison until production resolves a concrete route.
478
479 `schema_version` is bumped whenever a field is renamed or removed, so scripted
480 consumers can detect an incompatible manifest instead of silently reading
481 `null`. The current shape is `8`: in addition to the v7 provenance and
482 canonical-size contract, authoritative Work state and the active goal token-
483 budget terminal gate are explicit fail-closed dependencies of an exact
484 outbound request.
485
486 ## What is still approximate
487
488 Auto routing is not approximated: its provider-backed classifier is never run
489 by preview, and the route-dependent sections are typed unavailable instead.
490
491 Working-set drift used to be listed here. It no longer applies: a real submit
492 calls `working_set.observe_user_message` before it builds `<turn_meta>`, and
493 the preview now performs that same observation on a **clone** of the working
494 set and builds the block from that snapshot. Same bytes, no session write.
495
496 Everything else that used to be "approximate" is now typed. If a runtime
497 transform would change the request, the body section says so and publishes no
498 bytes at all, rather than publishing numbers that are nearly right.
499
500 ## Credit
501
502 The `dryrun` concept — preview the next request from the real request-building
503 seam rather than a hand-rolled summary — was harvested from PR #1099 by
504 [@GTC2080](https://github.com/GTC2080) (TaoMu). No code from that PR is
505 reused; the implementation here is written against Codewhale's current
506 multi-dialect client.
507
507 lines MARKDOWN