返回 CodeWhale
preview_request.rs
根目录 / crates / tui / src / commands / groups / debug / preview_request.rs
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
416 lines RUST