| 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 crate::tui::app::{App, AppAction}; |
| 46 | |
| 47 | /// Usage line, kept in one place so the error path and the docs agree. |
| 48 | /// |
| 49 | /// `--prompt` is terminal by construction: everything after it is prompt text. |
| 50 | /// That is what makes flag placement unambiguous instead of merely documented. |
| 51 | const USAGE: &str = "Usage: /preview-request [json] [--prompt <text>] | base-prompt \ |
| 52 | (flags first; --prompt takes the rest)"; |
| 53 | |
| 54 | /// Entry point for `/preview-request` (aliases `/dryrun`, `/preview_request`). |
| 55 | pub fn preview_request(_app: &mut App, arg: Option<&str>) -> CommandResult { |
| 56 | match parse_args(arg.unwrap_or_default()) { |
| 57 | Ok(PreviewArgs { |
| 58 | json, |
| 59 | base_prompt_only, |
| 60 | hypothetical_prompt, |
| 61 | }) => CommandResult::action(AppAction::PreviewOutboundRequest { |
| 62 | json, |
| 63 | base_prompt_only, |
| 64 | hypothetical_prompt, |
| 65 | }), |
| 66 | Err(message) => CommandResult::message(message), |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 71 | struct PreviewArgs { |
| 72 | json: bool, |
| 73 | base_prompt_only: bool, |
| 74 | hypothetical_prompt: Option<String>, |
| 75 | } |
| 76 | |
| 77 | /// Parse `/preview-request` arguments. |
| 78 | /// |
| 79 | /// # Grammar |
| 80 | /// |
| 81 | /// ```text |
| 82 | /// args := flag* [ "--prompt" WS+ prompt ] |
| 83 | /// flag := "json" | "--json" | "manifest" | "--manifest" |
| 84 | /// | "prompt" | "base-prompt" | "--base-prompt" |
| 85 | /// prompt := <every remaining byte, verbatim> |
| 86 | /// ``` |
| 87 | /// |
| 88 | /// Two properties this grammar exists to guarantee, both of which the first |
| 89 | /// implementation claimed and did not have: |
| 90 | /// |
| 91 | /// - **Flag placement is truthful.** Flags come *before* `--prompt`; `--prompt` |
| 92 | /// is terminal. The old parser advertised "any order" while consuming every |
| 93 | /// trailing token — including a trailing `json` — into the prompt, so |
| 94 | /// `--prompt fix it json` silently previewed the prompt *"fix it json"* as a |
| 95 | /// human table. There is now exactly one reading of any input. |
| 96 | /// - **The prompt is byte-preserving.** The old parser did |
| 97 | /// `split_whitespace().join(" ")`, which collapsed every run of whitespace |
| 98 | /// and every newline. The hypothetical prompt is part of the request being |
| 99 | /// hashed, so collapsing it described a body that differed from the real one |
| 100 | /// in the one field the user typed. Only one whitespace codepoint that |
| 101 | /// *delimits* `--prompt` from its text is removed; everything after it — |
| 102 | /// additional leading whitespace, interior runs, newlines, trailing bytes — |
| 103 | /// survives exactly. |
| 104 | /// |
| 105 | /// Anything before `--prompt` that is not a known flag is rejected rather than |
| 106 | /// guessed at, and because `--prompt` swallows the remainder there is no |
| 107 | /// trailing-argument position left to be ambiguous. |
| 108 | /// |
| 109 | /// `base-prompt` / `--base-prompt` explicitly render only the exact effective |
| 110 | /// base prompt. They cannot be combined with JSON or a hypothetical prompt. |
| 111 | /// The effective system prompt remains protected: the ordinary manifest shows |
| 112 | /// only its canonical JSON size and hash because it may contain project |
| 113 | /// instructions, skills, and memory. `prompt` remains a compatibility alias |
| 114 | /// for the ordinary manifest and never dumps effective system text. |
| 115 | fn parse_args(raw: &str) -> Result<PreviewArgs, String> { |
| 116 | const PROMPT_FLAG: &str = "--prompt"; |
| 117 | let mut json = false; |
| 118 | let mut base_prompt_only = false; |
| 119 | let mut rest = raw; |
| 120 | |
| 121 | loop { |
| 122 | let trimmed = rest.trim_start(); |
| 123 | if trimmed.is_empty() { |
| 124 | return Ok(PreviewArgs { |
| 125 | json, |
| 126 | base_prompt_only, |
| 127 | hypothetical_prompt: None, |
| 128 | }); |
| 129 | } |
| 130 | let token_end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len()); |
| 131 | let (token, remainder) = trimmed.split_at(token_end); |
| 132 | |
| 133 | if token == PROMPT_FLAG { |
| 134 | if base_prompt_only { |
| 135 | return Err(format!( |
| 136 | "`base-prompt` cannot be combined with `--prompt`. {USAGE}" |
| 137 | )); |
| 138 | } |
| 139 | // Consume exactly one whitespace codepoint as syntax. Any further |
| 140 | // leading whitespace belongs to the prompt, just like trailing |
| 141 | // whitespace and newlines do. The command dispatcher deliberately |
| 142 | // preserves this raw remainder. |
| 143 | let Some(delimiter) = remainder.chars().next().filter(|ch| ch.is_whitespace()) else { |
| 144 | return Err(format!("`--prompt` needs text after it. {USAGE}")); |
| 145 | }; |
| 146 | let prompt = &remainder[delimiter.len_utf8()..]; |
| 147 | if prompt.trim().is_empty() { |
| 148 | return Err(format!("`--prompt` needs text after it. {USAGE}")); |
| 149 | } |
| 150 | return Ok(PreviewArgs { |
| 151 | json, |
| 152 | base_prompt_only, |
| 153 | hypothetical_prompt: Some(prompt.to_string()), |
| 154 | }); |
| 155 | } |
| 156 | |
| 157 | match token { |
| 158 | "json" | "--json" => { |
| 159 | if base_prompt_only { |
| 160 | return Err(format!( |
| 161 | "`base-prompt` cannot be combined with JSON. {USAGE}" |
| 162 | )); |
| 163 | } |
| 164 | json = true; |
| 165 | } |
| 166 | "manifest" | "--manifest" => json = false, |
| 167 | "prompt" => {} |
| 168 | "base-prompt" | "--base-prompt" => { |
| 169 | if json { |
| 170 | return Err(format!( |
| 171 | "`base-prompt` cannot be combined with JSON. {USAGE}" |
| 172 | )); |
| 173 | } |
| 174 | base_prompt_only = true; |
| 175 | } |
| 176 | _ => { |
| 177 | return Err(format!( |
| 178 | "Unknown argument. Flags come before `--prompt`, which takes the rest of the line as prompt text. {USAGE}" |
| 179 | )); |
| 180 | } |
| 181 | } |
| 182 | rest = remainder; |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | #[cfg(test)] |
| 187 | mod tests { |
| 188 | use super::*; |
| 189 | use crate::config::Config; |
| 190 | |
| 191 | fn args(raw: &str) -> Result<PreviewArgs, String> { |
| 192 | parse_args(raw) |
| 193 | } |
| 194 | |
| 195 | #[test] |
| 196 | fn default_invocation_requests_the_human_manifest() { |
| 197 | assert_eq!( |
| 198 | args("").unwrap(), |
| 199 | PreviewArgs { |
| 200 | json: false, |
| 201 | base_prompt_only: false, |
| 202 | hypothetical_prompt: None |
| 203 | } |
| 204 | ); |
| 205 | assert_eq!(args("manifest").unwrap(), args("").unwrap()); |
| 206 | } |
| 207 | |
| 208 | #[test] |
| 209 | fn json_flag_is_accepted_in_both_spellings() { |
| 210 | assert!(args("json").unwrap().json); |
| 211 | assert!(args("--json").unwrap().json); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn base_prompt_mode_is_explicit_and_cannot_mix_with_body_preview() { |
| 216 | assert!(!args("prompt").unwrap().base_prompt_only); |
| 217 | for alias in ["base-prompt", "--base-prompt"] { |
| 218 | let parsed = args(alias).expect("base-prompt mode parses"); |
| 219 | assert!(parsed.base_prompt_only, "{alias}"); |
| 220 | assert_eq!(parsed.hypothetical_prompt, None, "{alias}"); |
| 221 | assert!(!parsed.json, "{alias}"); |
| 222 | } |
| 223 | for invalid in [ |
| 224 | "json base-prompt", |
| 225 | "base-prompt json", |
| 226 | "base-prompt --prompt hi", |
| 227 | ] { |
| 228 | assert!(args(invalid).is_err(), "{invalid}"); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn hypothetical_prompt_is_captured_verbatim_for_auto_resolution() { |
| 234 | assert_eq!( |
| 235 | args("--prompt refactor the parser").unwrap(), |
| 236 | PreviewArgs { |
| 237 | json: false, |
| 238 | base_prompt_only: false, |
| 239 | hypothetical_prompt: Some("refactor the parser".to_string()), |
| 240 | } |
| 241 | ); |
| 242 | assert_eq!( |
| 243 | args("json --prompt fix the failing test").unwrap(), |
| 244 | PreviewArgs { |
| 245 | json: true, |
| 246 | base_prompt_only: false, |
| 247 | hypothetical_prompt: Some("fix the failing test".to_string()), |
| 248 | } |
| 249 | ); |
| 250 | } |
| 251 | |
| 252 | /// The prompt is hashed into the previewed body, so collapsing its bytes |
| 253 | /// described a request that differed from the real one in exactly the |
| 254 | /// field the user typed. `split_whitespace().join(" ")` did that. |
| 255 | #[test] |
| 256 | fn the_prompt_keeps_the_users_bytes() { |
| 257 | for prompt in [ |
| 258 | "keep two spaces", |
| 259 | "line one\nline two", |
| 260 | "tabs\tand\tmore", |
| 261 | "trailing space ", |
| 262 | ] { |
| 263 | let raw = format!("--prompt {prompt}"); |
| 264 | let parsed = args(&raw).expect("prompt parses"); |
| 265 | assert_eq!( |
| 266 | parsed.hypothetical_prompt.as_deref(), |
| 267 | Some(prompt), |
| 268 | "`{prompt:?}` must survive the parser byte for byte" |
| 269 | ); |
| 270 | } |
| 271 | // Only one codepoint delimits the flag from its text. The other three |
| 272 | // spaces are prompt bytes. |
| 273 | assert_eq!( |
| 274 | args("--prompt padded start") |
| 275 | .unwrap() |
| 276 | .hypothetical_prompt |
| 277 | .as_deref(), |
| 278 | Some(" padded start") |
| 279 | ); |
| 280 | } |
| 281 | |
| 282 | /// The old parser advertised "any order" and then swallowed every trailing |
| 283 | /// token into the prompt, so a trailing `json` silently became prompt text. |
| 284 | /// Flags are now unambiguously *before* `--prompt`. |
| 285 | #[test] |
| 286 | fn flags_after_the_prompt_are_prompt_text_not_flags() { |
| 287 | let parsed = args("--prompt fix it json").expect("parses"); |
| 288 | assert!( |
| 289 | !parsed.json, |
| 290 | "a trailing `json` is part of the prompt, and the manifest stays human" |
| 291 | ); |
| 292 | assert_eq!(parsed.hypothetical_prompt.as_deref(), Some("fix it json")); |
| 293 | |
| 294 | // The truthful spelling puts the flag first, and it works. |
| 295 | let parsed = args("json --prompt fix it").expect("parses"); |
| 296 | assert!(parsed.json); |
| 297 | assert_eq!(parsed.hypothetical_prompt.as_deref(), Some("fix it")); |
| 298 | } |
| 299 | |
| 300 | #[test] |
| 301 | fn unknown_arguments_before_the_prompt_are_rejected_not_guessed() { |
| 302 | for raw in ["nope", "json nope", "--nope --prompt hi", "manifest -x"] { |
| 303 | let err = args(raw).expect_err("an unknown argument must not parse"); |
| 304 | assert!(err.contains("Unknown argument"), "{raw}: {err}"); |
| 305 | assert!(err.contains("--prompt"), "{raw}: {err}"); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | #[test] |
| 310 | fn unknown_argument_diagnostic_is_bounded_and_never_echoes_input() { |
| 311 | let hostile = format!( |
| 312 | "sk-live-{}-/Users/alice/private/config\nsecond-line", |
| 313 | "a".repeat(10_000) |
| 314 | ); |
| 315 | let err = args(&hostile).expect_err("hostile input must be rejected"); |
| 316 | assert!(err.contains("Unknown argument"), "{err}"); |
| 317 | assert!(err.contains("--prompt"), "{err}"); |
| 318 | assert!(err.len() < 256, "diagnostic was not bounded: {}", err.len()); |
| 319 | for forbidden in ["sk-live", "/Users/alice", "second-line"] { |
| 320 | assert!(!err.contains(forbidden), "{forbidden} leaked in {err}"); |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | #[test] |
| 325 | fn empty_hypothetical_prompt_is_rejected() { |
| 326 | for raw in ["--prompt", "--prompt ", "json --prompt"] { |
| 327 | let err = args(raw).expect_err("bare --prompt is an error"); |
| 328 | assert!(err.contains("needs text"), "{raw}: {err}"); |
| 329 | assert!(err.contains("/preview-request"), "{raw}: {err}"); |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | #[test] |
| 334 | fn leading_and_repeated_whitespace_between_flags_is_ignored() { |
| 335 | assert_eq!(args(" json --manifest ").unwrap(), args("").unwrap()); |
| 336 | } |
| 337 | |
| 338 | #[test] |
| 339 | fn unknown_argument_is_rejected_without_touching_state() { |
| 340 | let options = crate::test_support::test_tui_options(std::path::PathBuf::from( |
| 341 | "/tmp/test-workspace-preview-request", |
| 342 | )); |
| 343 | let mut app = App::new(options, &Config::default()); |
| 344 | let messages_before = app.api_messages.len(); |
| 345 | let history_before = app.history.len(); |
| 346 | |
| 347 | let result = preview_request(&mut app, Some("nope")); |
| 348 | |
| 349 | assert!(!result.is_error); |
| 350 | assert!( |
| 351 | result |
| 352 | .message |
| 353 | .as_deref() |
| 354 | .is_some_and(|message| message.contains("/preview-request")), |
| 355 | "{result:?}" |
| 356 | ); |
| 357 | assert!(result.action.is_none()); |
| 358 | assert_eq!(app.api_messages.len(), messages_before); |
| 359 | assert_eq!(app.history.len(), history_before); |
| 360 | } |
| 361 | |
| 362 | #[test] |
| 363 | fn command_delegates_to_the_engine_and_mutates_nothing() { |
| 364 | let options = crate::test_support::test_tui_options(std::path::PathBuf::from( |
| 365 | "/tmp/test-workspace-preview-request-pure", |
| 366 | )); |
| 367 | let mut app = App::new(options, &Config::default()); |
| 368 | app.api_messages.push(crate::models::Message { |
| 369 | role: "user".to_string(), |
| 370 | content: vec![crate::models::ContentBlock::Text { |
| 371 | text: "hello".to_string(), |
| 372 | cache_control: None, |
| 373 | }], |
| 374 | }); |
| 375 | |
| 376 | let result = preview_request(&mut app, Some("json")); |
| 377 | |
| 378 | // The command itself renders nothing: the engine is the authority. |
| 379 | assert!(result.message.is_none(), "{result:?}"); |
| 380 | assert!(matches!( |
| 381 | result.action, |
| 382 | Some(AppAction::PreviewOutboundRequest { json: true, .. }) |
| 383 | )); |
| 384 | assert_eq!(app.api_messages.len(), 1); |
| 385 | assert!(app.history.is_empty()); |
| 386 | } |
| 387 | |
| 388 | #[test] |
| 389 | fn base_prompt_provenance_is_runtime_not_a_source_path() { |
| 390 | let label = crate::prompts::base_prompt_origin().label(); |
| 391 | assert!(!label.contains("crates/"), "{label}"); |
| 392 | assert!(!label.contains(".rs"), "{label}"); |
| 393 | assert!( |
| 394 | label.contains("bundled") || label.contains("override"), |
| 395 | "{label}" |
| 396 | ); |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn this_command_contains_no_prompt_dumping_path() { |
| 401 | // Guard against the removed disclosure being reintroduced here: the |
| 402 | // source of this module must not reference the prompt-text helpers. |
| 403 | let source = include_str!("preview_request.rs"); |
| 404 | for forbidden in [ |
| 405 | "effective_base_prompt_text", |
| 406 | "system_prompt_text", |
| 407 | "compose_default_static_layers", |
| 408 | ] { |
| 409 | assert!( |
| 410 | !source.contains(&format!("{forbidden}(")), |
| 411 | "`{forbidden}` must not be callable from the command layer" |
| 412 | ); |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 |