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