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