返回 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 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
377 lines RUST