| 1 | //! `/preview-request` — offline, redacted preview of the next outbound |
| 2 | //! request (#1004), plus typed base-prompt provenance (#3928). |
| 3 | //! |
| 4 | //! This module is a thin dispatcher. It owns argument parsing and nothing |
| 5 | //! else: the manifest is built by the **engine** |
| 6 | //! (`core::engine::preview`), which is the only authority that can rebuild |
| 7 | //! the exact next-turn tool catalog, active subset, MCP state, mode, gates, |
| 8 | //! permission posture, tool choice, and resolved route, and then run them |
| 9 | //! through the shared prepared-request seam every provider dialect uses. |
| 10 | //! |
| 11 | //! What this command will never do: |
| 12 | //! |
| 13 | //! - print effective system text, project instructions, memory, or skill text; |
| 14 | //! the explicit `base-prompt` mode prints only the base layer; |
| 15 | //! - print message content, tool results, or attachment payloads; |
| 16 | //! - print credentials, URL paths, or absolute workspace paths; |
| 17 | //! - export the request body. |
| 18 | //! |
| 19 | //! It is an inspectability slice: typed counts, hashes, enums, and short |
| 20 | //! provenance labels. Human command only — deliberately not a model-visible |
| 21 | //! tool. |
| 22 | //! |
| 23 | //! Two things are worth knowing before reading the output: |
| 24 | //! |
| 25 | //! - **`--prompt <text>` is necessary for an exact manifest, and not always |
| 26 | //! sufficient.** The next user message is part of the request, and under |
| 27 | //! auto model routing it also decides the route; without it the route and |
| 28 | //! body sections report a typed unavailable state instead of describing the |
| 29 | //! previous turn. With it, a section is still typed unavailable whenever a |
| 30 | //! real turn would do something an inspection may not — run `message_submit` |
| 31 | //! hooks, connect MCP servers, auto-compact, recover from a context |
| 32 | //! overflow, or consume queued sub-agent completions and LSP diagnostics. |
| 33 | //! Exactness is conditional and the manifest says which condition failed. |
| 34 | //! - **Flags come before `--prompt`, which takes the rest of the line.** See |
| 35 | //! [`parse_args`] for the grammar and why it is not "any order". |
| 36 | //! - **Preview never calls a provider or model.** Auto routing therefore |
| 37 | //! reports a typed unavailable state even with `--prompt`: production must |
| 38 | //! run the classifier before that route can be known exactly. |
| 39 | //! |
| 40 | //! The `dryrun` concept — preview the next request from the real |
| 41 | //! request-building seam rather than a hand-rolled summary — is harvested |
| 42 | //! from PR #1099 by TaoMu (GTC2080); no code from that PR is reused. |
| 43 | |
| 44 | use super::CommandResult; |
| 45 | use super::DebugAction as AppAction; |
| 46 | use codewhale_command_contract::handler::CommandHandler; |
| 47 | use codewhale_command_contract::metadata::{ |
| 48 | CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, |
| 49 | }; |
| 50 | |
| 51 | pub(in crate::commands) struct PreviewRequestCmd; |
| 52 | |
| 53 | const CONTRACT_INFO: ContractInfo = ContractInfo { |
| 54 | name: "preview-request", |
| 55 | aliases: &["dryrun", "preview_request"], |
| 56 | usage: "/preview-request [json] [--prompt <text>]", |
| 57 | description_key: "cmd_preview_request_description", |
| 58 | }; |
| 59 | |
| 60 | impl ContractRegisterCommand<CommandResult> for PreviewRequestCmd { |
| 61 | fn info() -> &'static ContractInfo { |
| 62 | &CONTRACT_INFO |
| 63 | } |
| 64 | |
| 65 | fn handler() -> CommandHandler<CommandResult> { |
| 66 | CommandHandler::Pure(preview_request) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | /// Usage line, kept in one place so the error path and the docs agree. |
| 71 | /// |
| 72 | /// `--prompt` is terminal by construction: everything after it is prompt text. |
| 73 | /// That is what makes flag placement unambiguous instead of merely documented. |
| 74 | const USAGE: &str = "Usage: /preview-request [json] [--prompt <text>] | base-prompt \ |
| 75 | (flags first; --prompt takes the rest)"; |
| 76 | |
| 77 | /// Entry point for `/preview-request` (aliases `/dryrun`, `/preview_request`). |
| 78 | pub fn preview_request(arg: Option<&str>) -> CommandResult { |
| 79 | match parse_args(arg.unwrap_or_default()) { |
| 80 | Ok(PreviewArgs { |
| 81 | json, |
| 82 | base_prompt_only, |
| 83 | hypothetical_prompt, |
| 84 | }) => CommandResult::action(AppAction::PreviewOutboundRequest { |
| 85 | json, |
| 86 | base_prompt_only, |
| 87 | hypothetical_prompt, |
| 88 | }), |
| 89 | Err(message) => CommandResult::message(message), |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 94 | struct PreviewArgs { |
| 95 | json: bool, |
| 96 | base_prompt_only: bool, |
| 97 | hypothetical_prompt: Option<String>, |
| 98 | } |
| 99 | |
| 100 | /// Parse `/preview-request` arguments. |
| 101 | /// |
| 102 | /// # Grammar |
| 103 | /// |
| 104 | /// ```text |
| 105 | /// args := flag* [ "--prompt" WS+ prompt ] |
| 106 | /// flag := "json" | "--json" | "manifest" | "--manifest" |
| 107 | /// | "prompt" | "base-prompt" | "--base-prompt" |
| 108 | /// prompt := <every remaining byte, verbatim> |
| 109 | /// ``` |
| 110 | /// |
| 111 | /// Two properties this grammar exists to guarantee, both of which the first |
| 112 | /// implementation claimed and did not have: |
| 113 | /// |
| 114 | /// - **Flag placement is truthful.** Flags come *before* `--prompt`; `--prompt` |
| 115 | /// is terminal. The old parser advertised "any order" while consuming every |
| 116 | /// trailing token — including a trailing `json` — into the prompt, so |
| 117 | /// `--prompt fix it json` silently previewed the prompt *"fix it json"* as a |
| 118 | /// human table. There is now exactly one reading of any input. |
| 119 | /// - **The prompt is byte-preserving.** The old parser did |
| 120 | /// `split_whitespace().join(" ")`, which collapsed every run of whitespace |
| 121 | /// and every newline. The hypothetical prompt is part of the request being |
| 122 | /// hashed, so collapsing it described a body that differed from the real one |
| 123 | /// in the one field the user typed. Only one whitespace codepoint that |
| 124 | /// *delimits* `--prompt` from its text is removed; everything after it — |
| 125 | /// additional leading whitespace, interior runs, newlines, trailing bytes — |
| 126 | /// survives exactly. |
| 127 | /// |
| 128 | /// Anything before `--prompt` that is not a known flag is rejected rather than |
| 129 | /// guessed at, and because `--prompt` swallows the remainder there is no |
| 130 | /// trailing-argument position left to be ambiguous. |
| 131 | /// |
| 132 | /// `base-prompt` / `--base-prompt` explicitly render only the exact effective |
| 133 | /// base prompt. They cannot be combined with JSON or a hypothetical prompt. |
| 134 | /// The effective system prompt remains protected: the ordinary manifest shows |
| 135 | /// only its canonical JSON size and hash because it may contain project |
| 136 | /// instructions, skills, and memory. `prompt` remains a compatibility alias |
| 137 | /// for the ordinary manifest and never dumps effective system text. |
| 138 | fn parse_args(raw: &str) -> Result<PreviewArgs, String> { |
| 139 | const PROMPT_FLAG: &str = "--prompt"; |
| 140 | let mut json = false; |
| 141 | let mut base_prompt_only = false; |
| 142 | let mut rest = raw; |
| 143 | |
| 144 | loop { |
| 145 | let trimmed = rest.trim_start(); |
| 146 | if trimmed.is_empty() { |
| 147 | return Ok(PreviewArgs { |
| 148 | json, |
| 149 | base_prompt_only, |
| 150 | hypothetical_prompt: None, |
| 151 | }); |
| 152 | } |
| 153 | let token_end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len()); |
| 154 | let (token, remainder) = trimmed.split_at(token_end); |
| 155 | |
| 156 | if token == PROMPT_FLAG { |
| 157 | if base_prompt_only { |
| 158 | return Err(format!( |
| 159 | "`base-prompt` cannot be combined with `--prompt`. {USAGE}" |
| 160 | )); |
| 161 | } |
| 162 | // Consume exactly one whitespace codepoint as syntax. Any further |
| 163 | // leading whitespace belongs to the prompt, just like trailing |
| 164 | // whitespace and newlines do. The command dispatcher deliberately |
| 165 | // preserves this raw remainder. |
| 166 | let Some(delimiter) = remainder.chars().next().filter(|ch| ch.is_whitespace()) else { |
| 167 | return Err(format!("`--prompt` needs text after it. {USAGE}")); |
| 168 | }; |
| 169 | let prompt = &remainder[delimiter.len_utf8()..]; |
| 170 | if prompt.trim().is_empty() { |
| 171 | return Err(format!("`--prompt` needs text after it. {USAGE}")); |
| 172 | } |
| 173 | return Ok(PreviewArgs { |
| 174 | json, |
| 175 | base_prompt_only, |
| 176 | hypothetical_prompt: Some(prompt.to_string()), |
| 177 | }); |
| 178 | } |
| 179 | |
| 180 | match token { |
| 181 | "json" | "--json" => { |
| 182 | if base_prompt_only { |
| 183 | return Err(format!( |
| 184 | "`base-prompt` cannot be combined with JSON. {USAGE}" |
| 185 | )); |
| 186 | } |
| 187 | json = true; |
| 188 | } |
| 189 | "manifest" | "--manifest" => json = false, |
| 190 | "prompt" => {} |
| 191 | "base-prompt" | "--base-prompt" => { |
| 192 | if json { |
| 193 | return Err(format!( |
| 194 | "`base-prompt` cannot be combined with JSON. {USAGE}" |
| 195 | )); |
| 196 | } |
| 197 | base_prompt_only = true; |
| 198 | } |
| 199 | _ => { |
| 200 | return Err(format!( |
| 201 | "Unknown argument. Flags come before `--prompt`, which takes the rest of the line as prompt text. {USAGE}" |
| 202 | )); |
| 203 | } |
| 204 | } |
| 205 | rest = remainder; |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | #[cfg(test)] |
| 210 | mod tests { |
| 211 | use super::*; |
| 212 | |
| 213 | fn args(raw: &str) -> Result<PreviewArgs, String> { |
| 214 | parse_args(raw) |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn default_invocation_requests_the_human_manifest() { |
| 219 | assert_eq!( |
| 220 | args("").unwrap(), |
| 221 | PreviewArgs { |
| 222 | json: false, |
| 223 | base_prompt_only: false, |
| 224 | hypothetical_prompt: None |
| 225 | } |
| 226 | ); |
| 227 | assert_eq!(args("manifest").unwrap(), args("").unwrap()); |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn json_flag_is_accepted_in_both_spellings() { |
| 232 | assert!(args("json").unwrap().json); |
| 233 | assert!(args("--json").unwrap().json); |
| 234 | } |
| 235 | |
| 236 | #[test] |
| 237 | fn base_prompt_mode_is_explicit_and_cannot_mix_with_body_preview() { |
| 238 | assert!(!args("prompt").unwrap().base_prompt_only); |
| 239 | for alias in ["base-prompt", "--base-prompt"] { |
| 240 | let parsed = args(alias).expect("base-prompt mode parses"); |
| 241 | assert!(parsed.base_prompt_only, "{alias}"); |
| 242 | assert_eq!(parsed.hypothetical_prompt, None, "{alias}"); |
| 243 | assert!(!parsed.json, "{alias}"); |
| 244 | } |
| 245 | for invalid in [ |
| 246 | "json base-prompt", |
| 247 | "base-prompt json", |
| 248 | "base-prompt --prompt hi", |
| 249 | ] { |
| 250 | assert!(args(invalid).is_err(), "{invalid}"); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn hypothetical_prompt_is_captured_verbatim_for_auto_resolution() { |
| 256 | assert_eq!( |
| 257 | args("--prompt refactor the parser").unwrap(), |
| 258 | PreviewArgs { |
| 259 | json: false, |
| 260 | base_prompt_only: false, |
| 261 | hypothetical_prompt: Some("refactor the parser".to_string()), |
| 262 | } |
| 263 | ); |
| 264 | assert_eq!( |
| 265 | args("json --prompt fix the failing test").unwrap(), |
| 266 | PreviewArgs { |
| 267 | json: true, |
| 268 | base_prompt_only: false, |
| 269 | hypothetical_prompt: Some("fix the failing test".to_string()), |
| 270 | } |
| 271 | ); |
| 272 | } |
| 273 | |
| 274 | /// The prompt is hashed into the previewed body, so collapsing its bytes |
| 275 | /// described a request that differed from the real one in exactly the |
| 276 | /// field the user typed. `split_whitespace().join(" ")` did that. |
| 277 | #[test] |
| 278 | fn the_prompt_keeps_the_users_bytes() { |
| 279 | for prompt in [ |
| 280 | "keep two spaces", |
| 281 | "line one\nline two", |
| 282 | "tabs\tand\tmore", |
| 283 | "trailing space ", |
| 284 | ] { |
| 285 | let raw = format!("--prompt {prompt}"); |
| 286 | let parsed = args(&raw).expect("prompt parses"); |
| 287 | assert_eq!( |
| 288 | parsed.hypothetical_prompt.as_deref(), |
| 289 | Some(prompt), |
| 290 | "`{prompt:?}` must survive the parser byte for byte" |
| 291 | ); |
| 292 | } |
| 293 | // Only one codepoint delimits the flag from its text. The other three |
| 294 | // spaces are prompt bytes. |
| 295 | assert_eq!( |
| 296 | args("--prompt padded start") |
| 297 | .unwrap() |
| 298 | .hypothetical_prompt |
| 299 | .as_deref(), |
| 300 | Some(" padded start") |
| 301 | ); |
| 302 | } |
| 303 | |
| 304 | /// The old parser advertised "any order" and then swallowed every trailing |
| 305 | /// token into the prompt, so a trailing `json` silently became prompt text. |
| 306 | /// Flags are now unambiguously *before* `--prompt`. |
| 307 | #[test] |
| 308 | fn flags_after_the_prompt_are_prompt_text_not_flags() { |
| 309 | let parsed = args("--prompt fix it json").expect("parses"); |
| 310 | assert!( |
| 311 | !parsed.json, |
| 312 | "a trailing `json` is part of the prompt, and the manifest stays human" |
| 313 | ); |
| 314 | assert_eq!(parsed.hypothetical_prompt.as_deref(), Some("fix it json")); |
| 315 | |
| 316 | // The truthful spelling puts the flag first, and it works. |
| 317 | let parsed = args("json --prompt fix it").expect("parses"); |
| 318 | assert!(parsed.json); |
| 319 | assert_eq!(parsed.hypothetical_prompt.as_deref(), Some("fix it")); |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn unknown_arguments_before_the_prompt_are_rejected_not_guessed() { |
| 324 | for raw in ["nope", "json nope", "--nope --prompt hi", "manifest -x"] { |
| 325 | let err = args(raw).expect_err("an unknown argument must not parse"); |
| 326 | assert!(err.contains("Unknown argument"), "{raw}: {err}"); |
| 327 | assert!(err.contains("--prompt"), "{raw}: {err}"); |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | #[test] |
| 332 | fn unknown_argument_diagnostic_is_bounded_and_never_echoes_input() { |
| 333 | let hostile = format!( |
| 334 | "sk-live-{}-/Users/alice/private/config\nsecond-line", |
| 335 | "a".repeat(10_000) |
| 336 | ); |
| 337 | let err = args(&hostile).expect_err("hostile input must be rejected"); |
| 338 | assert!(err.contains("Unknown argument"), "{err}"); |
| 339 | assert!(err.contains("--prompt"), "{err}"); |
| 340 | assert!(err.len() < 256, "diagnostic was not bounded: {}", err.len()); |
| 341 | for forbidden in ["sk-live", "/Users/alice", "second-line"] { |
| 342 | assert!(!err.contains(forbidden), "{forbidden} leaked in {err}"); |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn empty_hypothetical_prompt_is_rejected() { |
| 348 | for raw in ["--prompt", "--prompt ", "json --prompt"] { |
| 349 | let err = args(raw).expect_err("bare --prompt is an error"); |
| 350 | assert!(err.contains("needs text"), "{raw}: {err}"); |
| 351 | assert!(err.contains("/preview-request"), "{raw}: {err}"); |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | #[test] |
| 356 | fn leading_and_repeated_whitespace_between_flags_is_ignored() { |
| 357 | assert_eq!(args(" json --manifest ").unwrap(), args("").unwrap()); |
| 358 | } |
| 359 | |
| 360 | #[test] |
| 361 | fn this_command_contains_no_prompt_dumping_path() { |
| 362 | // Guard against the removed disclosure being reintroduced here: the |
| 363 | // source of this module must not reference the prompt-text helpers. |
| 364 | let source = include_str!("preview_request.rs"); |
| 365 | for forbidden in [ |
| 366 | "effective_base_prompt_text", |
| 367 | "system_prompt_text", |
| 368 | "compose_default_static_layers", |
| 369 | ] { |
| 370 | assert!( |
| 371 | !source.contains(&format!("{forbidden}(")), |
| 372 | "`{forbidden}` must not be callable from the command layer" |
| 373 | ); |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 |