返回 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 use codewhale_models::Role;
191
192 fn args(raw: &str) -> Result<PreviewArgs, String> {
193 parse_args(raw)
194 }
195
196 #[test]
197 fn default_invocation_requests_the_human_manifest() {
198 assert_eq!(
199 args("").unwrap(),
200 PreviewArgs {
201 json: false,
202 base_prompt_only: false,
203 hypothetical_prompt: None
204 }
205 );
206 assert_eq!(args("manifest").unwrap(), args("").unwrap());
207 }
208
209 #[test]
210 fn json_flag_is_accepted_in_both_spellings() {
211 assert!(args("json").unwrap().json);
212 assert!(args("--json").unwrap().json);
213 }
214
215 #[test]
216 fn base_prompt_mode_is_explicit_and_cannot_mix_with_body_preview() {
217 assert!(!args("prompt").unwrap().base_prompt_only);
218 for alias in ["base-prompt", "--base-prompt"] {
219 let parsed = args(alias).expect("base-prompt mode parses");
220 assert!(parsed.base_prompt_only, "{alias}");
221 assert_eq!(parsed.hypothetical_prompt, None, "{alias}");
222 assert!(!parsed.json, "{alias}");
223 }
224 for invalid in [
225 "json base-prompt",
226 "base-prompt json",
227 "base-prompt --prompt hi",
228 ] {
229 assert!(args(invalid).is_err(), "{invalid}");
230 }
231 }
232
233 #[test]
234 fn hypothetical_prompt_is_captured_verbatim_for_auto_resolution() {
235 assert_eq!(
236 args("--prompt refactor the parser").unwrap(),
237 PreviewArgs {
238 json: false,
239 base_prompt_only: false,
240 hypothetical_prompt: Some("refactor the parser".to_string()),
241 }
242 );
243 assert_eq!(
244 args("json --prompt fix the failing test").unwrap(),
245 PreviewArgs {
246 json: true,
247 base_prompt_only: false,
248 hypothetical_prompt: Some("fix the failing test".to_string()),
249 }
250 );
251 }
252
253 /// The prompt is hashed into the previewed body, so collapsing its bytes
254 /// described a request that differed from the real one in exactly the
255 /// field the user typed. `split_whitespace().join(" ")` did that.
256 #[test]
257 fn the_prompt_keeps_the_users_bytes() {
258 for prompt in [
259 "keep two spaces",
260 "line one\nline two",
261 "tabs\tand\tmore",
262 "trailing space ",
263 ] {
264 let raw = format!("--prompt {prompt}");
265 let parsed = args(&raw).expect("prompt parses");
266 assert_eq!(
267 parsed.hypothetical_prompt.as_deref(),
268 Some(prompt),
269 "`{prompt:?}` must survive the parser byte for byte"
270 );
271 }
272 // Only one codepoint delimits the flag from its text. The other three
273 // spaces are prompt bytes.
274 assert_eq!(
275 args("--prompt padded start")
276 .unwrap()
277 .hypothetical_prompt
278 .as_deref(),
279 Some(" padded start")
280 );
281 }
282
283 /// The old parser advertised "any order" and then swallowed every trailing
284 /// token into the prompt, so a trailing `json` silently became prompt text.
285 /// Flags are now unambiguously *before* `--prompt`.
286 #[test]
287 fn flags_after_the_prompt_are_prompt_text_not_flags() {
288 let parsed = args("--prompt fix it json").expect("parses");
289 assert!(
290 !parsed.json,
291 "a trailing `json` is part of the prompt, and the manifest stays human"
292 );
293 assert_eq!(parsed.hypothetical_prompt.as_deref(), Some("fix it json"));
294
295 // The truthful spelling puts the flag first, and it works.
296 let parsed = args("json --prompt fix it").expect("parses");
297 assert!(parsed.json);
298 assert_eq!(parsed.hypothetical_prompt.as_deref(), Some("fix it"));
299 }
300
301 #[test]
302 fn unknown_arguments_before_the_prompt_are_rejected_not_guessed() {
303 for raw in ["nope", "json nope", "--nope --prompt hi", "manifest -x"] {
304 let err = args(raw).expect_err("an unknown argument must not parse");
305 assert!(err.contains("Unknown argument"), "{raw}: {err}");
306 assert!(err.contains("--prompt"), "{raw}: {err}");
307 }
308 }
309
310 #[test]
311 fn unknown_argument_diagnostic_is_bounded_and_never_echoes_input() {
312 let hostile = format!(
313 "sk-live-{}-/Users/alice/private/config\nsecond-line",
314 "a".repeat(10_000)
315 );
316 let err = args(&hostile).expect_err("hostile input must be rejected");
317 assert!(err.contains("Unknown argument"), "{err}");
318 assert!(err.contains("--prompt"), "{err}");
319 assert!(err.len() < 256, "diagnostic was not bounded: {}", err.len());
320 for forbidden in ["sk-live", "/Users/alice", "second-line"] {
321 assert!(!err.contains(forbidden), "{forbidden} leaked in {err}");
322 }
323 }
324
325 #[test]
326 fn empty_hypothetical_prompt_is_rejected() {
327 for raw in ["--prompt", "--prompt ", "json --prompt"] {
328 let err = args(raw).expect_err("bare --prompt is an error");
329 assert!(err.contains("needs text"), "{raw}: {err}");
330 assert!(err.contains("/preview-request"), "{raw}: {err}");
331 }
332 }
333
334 #[test]
335 fn leading_and_repeated_whitespace_between_flags_is_ignored() {
336 assert_eq!(args(" json --manifest ").unwrap(), args("").unwrap());
337 }
338
339 #[test]
340 fn unknown_argument_is_rejected_without_touching_state() {
341 let options = crate::test_support::test_tui_options(std::path::PathBuf::from(
342 "/tmp/test-workspace-preview-request",
343 ));
344 let mut app = App::new(options, &Config::default());
345 let messages_before = app.api_messages.len();
346 let history_before = app.history.len();
347
348 let result = preview_request(&mut app, Some("nope"));
349
350 assert!(!result.is_error);
351 assert!(
352 result
353 .message
354 .as_deref()
355 .is_some_and(|message| message.contains("/preview-request")),
356 "{result:?}"
357 );
358 assert!(result.action.is_none());
359 assert_eq!(app.api_messages.len(), messages_before);
360 assert_eq!(app.history.len(), history_before);
361 }
362
363 #[test]
364 fn command_delegates_to_the_engine_and_mutates_nothing() {
365 let options = crate::test_support::test_tui_options(std::path::PathBuf::from(
366 "/tmp/test-workspace-preview-request-pure",
367 ));
368 let mut app = App::new(options, &Config::default());
369 app.api_messages_mut().push(codewhale_models::Message {
370 role: Role::User,
371 content: vec![codewhale_models::ContentBlock::Text {
372 text: "hello".to_string(),
373 cache_control: None,
374 }],
375 });
376
377 let result = preview_request(&mut app, Some("json"));
378
379 // The command itself renders nothing: the engine is the authority.
380 assert!(result.message.is_none(), "{result:?}");
381 assert!(matches!(
382 result.action,
383 Some(AppAction::PreviewOutboundRequest { json: true, .. })
384 ));
385 assert_eq!(app.api_messages.len(), 1);
386 assert!(app.history.is_empty());
387 }
388
389 #[test]
390 fn base_prompt_provenance_is_runtime_not_a_source_path() {
391 let label = crate::prompts::base_prompt_origin().label();
392 assert!(!label.contains("crates/"), "{label}");
393 assert!(!label.contains(".rs"), "{label}");
394 assert!(
395 label.contains("bundled") || label.contains("override"),
396 "{label}"
397 );
398 }
399
400 #[test]
401 fn this_command_contains_no_prompt_dumping_path() {
402 // Guard against the removed disclosure being reintroduced here: the
403 // source of this module must not reference the prompt-text helpers.
404 let source = include_str!("preview_request.rs");
405 for forbidden in [
406 "effective_base_prompt_text",
407 "system_prompt_text",
408 "compose_default_static_layers",
409 ] {
410 assert!(
411 !source.contains(&format!("{forbidden}(")),
412 "`{forbidden}` must not be callable from the command layer"
413 );
414 }
415 }
416 }
417
417 lines RUST