Skip to main content

speccoder_runtime/approvals/
present.rs

1//! What a person sees when a call needs them: built live from the
2//! journaled [`Request`] and what only the live run knows — the file as it
3//! is now, the sandbox — and never journaled itself, so replay does not
4//! depend on the worktree. The terminal prompt, editors (ACP), the
5//! `approvals` listing and the dashboard all render this one value.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use super::Request;
12use crate::tools::policy::Object;
13
14/// Lines of a body shown before "v to view all".
15pub const HEAD: usize = 20;
16
17/// Files larger than this are described, not shown.
18const MAX_BYTES: usize = 1024 * 1024;
19
20/// What only the live run knows.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct PresentContext {
23    /// The worktree, to read the file a write or edit changes.
24    pub root: Option<PathBuf>,
25    /// The sandbox mechanism (`Seatbelt`, `Landlock+seccomp`); `None`: off.
26    pub sandbox: Option<String>,
27    /// What a command's network is without asking (`none`, `loopback`).
28    pub network: String,
29    /// The file's text, given instead of read from `root` (a saved attempt's).
30    pub file_text: Option<String>,
31}
32
33/// One choice: `once`, `task`, `run`, `always`, `reject` or `stop`.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Choice {
36    /// Its name.
37    pub choice: String,
38    /// Its key (`y`, `t`, `r`, `a`, `n`, `s`).
39    pub key: char,
40    /// Its number, fixed whether or not the others are offered.
41    pub number: u8,
42    /// What it does, the scope included.
43    pub label: String,
44}
45
46/// The call as a person sees it.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Presentation {
49    /// The question.
50    pub request: Request,
51    /// `Write this file?`, `Edit this file?`, `Run this command?`,
52    /// `Use the network?`, `Call MCP tool?`.
53    pub title: String,
54    /// The path, the whole command, or the MCP tool — never cut.
55    pub subject: String,
56    /// The object a decision covers, as lines: the file (and the name as
57    /// asked when it differs), the argv with its hash and the executable,
58    /// the tool with its digest; and what changed since an approval of the
59    /// same name, when something did.
60    pub object: Vec<String>,
61    /// Where arguments of the call were copied from: `contains text read
62    /// from README.md (turn 3): "…"`, then `and N more`.
63    pub sources: Vec<String>,
64    /// Said once, on the prompt that uses the run's asking budget.
65    pub notice: Option<String>,
66    /// The body as shown: a diff, a file's head, the command, arguments.
67    pub body: Vec<String>,
68    /// The whole body (`v`).
69    pub full: Vec<String>,
70    /// What the call can reach.
71    pub sandbox: String,
72    /// The choices, in order.
73    pub options: Vec<Choice>,
74    /// What an approval for the task or run covers (`this path, any content`).
75    pub scope: String,
76    /// The file's text before and after, for editors that show a diff.
77    pub old_text: Option<String>,
78    /// The file's text after the call.
79    pub new_text: Option<String>,
80    /// The file's absolute path, for a write or edit in a known worktree.
81    pub file: Option<String>,
82}
83
84/// Model text made safe for a terminal: escape (`\x1b`) as `␛`, other
85/// control characters as `^X`; newlines and tabs kept.
86#[must_use]
87pub fn escape(text: &str) -> String {
88    text.chars()
89        .map(|c| match c {
90            '\x1b' => "␛".to_owned(),
91            '\n' | '\t' => c.to_string(),
92            c if (c as u32) < 0x20 => format!("^{}", char::from(b'@' + c as u8)),
93            '\x7f' => "^?".to_owned(),
94            c => c.to_string(),
95        })
96        .collect()
97}
98
99/// A word an argv element needs no quotes for.
100fn plain(a: &str) -> bool {
101    !a.is_empty()
102        && a.chars()
103            .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%".contains(c))
104}
105
106/// The argv as a person would type it.
107#[must_use]
108pub fn shell_quote(argv: &[String]) -> String {
109    argv.iter()
110        .map(|a| {
111            if plain(a) {
112                a.clone()
113            } else {
114                format!("'{}'", a.replace('\'', "'\\''"))
115            }
116        })
117        .collect::<Vec<_>>()
118        .join(" ")
119}
120
121/// What "for the task" or "for this run" covers, by kind: always this
122/// exact object.
123#[must_use]
124pub fn scope_text(kind: &str) -> &'static str {
125    match kind {
126        "run" => "exactly this command in this directory",
127        "network" => "exactly this command in this directory, with the network",
128        "mcp" => "this tool as defined now, any arguments",
129        "agent" => "exactly this call again",
130        "reads" => "the agent's plain reads of one file each, inside the worktree",
131        _ => "this file, any content",
132    }
133}
134
135fn first12(hex: &str) -> &str {
136    hex.get(..12).unwrap_or(hex)
137}
138
139/// The object's lines: what a decision covers, shown in full.
140#[must_use]
141pub fn object_lines(object: Option<&Object>, changed: Option<&str>) -> Vec<String> {
142    let mut out = Vec::new();
143    match object {
144        Some(Object::Path { canonical, asked }) => {
145            out.push(format!("file  {}", escape(canonical)));
146            if asked != canonical {
147                out.push(format!(
148                    "asked as  {} (resolves to the file above)",
149                    escape(asked)
150                ));
151            }
152        }
153        Some(Object::Command {
154            argv,
155            argv_sha256,
156            cwd,
157            executable,
158            network,
159        }) => {
160            let json = serde_json::to_string(argv).unwrap_or_default();
161            out.push(format!(
162                "argv  {}  #{}",
163                escape(&json),
164                first12(argv_sha256)
165            ));
166            out.push(format!("runs  {}  in  {}", escape(executable), escape(cwd)));
167            if *network {
168                out.push("network  yes".to_owned());
169            }
170        }
171        Some(Object::Tool { name, digest }) => {
172            out.push(format!("tool  {}  @{}", escape(name), first12(digest)));
173        }
174        Some(Object::Agent {
175            agent,
176            call,
177            argvs,
178            paths,
179            ..
180        }) => {
181            out.push(format!(
182                "{} itself ({}), outside speccoder's sandbox",
183                escape(agent),
184                escape(call)
185            ));
186            for a in argvs {
187                out.push(format!("runs  {}", escape(&shell_quote(a))));
188            }
189            for p in paths {
190                out.push(format!("file  {}", escape(p)));
191            }
192        }
193        None => {}
194    }
195    if let Some(c) = changed {
196        out.push(format!("changed since approved: {}", escape(c)));
197    }
198    out
199}
200
201/// The source lines of a request.
202#[must_use]
203pub fn source_lines(request: &Request) -> Vec<String> {
204    let mut out: Vec<String> = request
205        .sources
206        .iter()
207        .map(|s| {
208            let when = if s.turn == 0 {
209                "the attempt's prompt".to_owned()
210            } else {
211                format!("turn {}", s.turn)
212            };
213            format!(
214                "contains text read from {} ({when}): \"{}\"",
215                escape(&s.from),
216                escape(&s.argument)
217            )
218        })
219        .collect();
220    if request.more > 0 {
221        out.push(format!("and {} more", request.more));
222    }
223    out
224}
225
226/// The notice on the prompt that uses the run's asking budget.
227#[must_use]
228pub fn budget_notice(request: &Request) -> Option<String> {
229    (request.budget > 0 && request.asked == request.budget && !request.batched).then(|| {
230        format!(
231            "This is prompt {0} of {0} for this run; further calls will wait in Needs You.",
232            request.budget
233        )
234    })
235}
236
237fn head(lines: &[String]) -> (Vec<String>, usize) {
238    let shown = lines.iter().take(HEAD).cloned().collect();
239    (shown, lines.len().saturating_sub(HEAD))
240}
241
242/// Numbered lines of a new file.
243fn numbered(text: &str) -> Vec<String> {
244    text.lines()
245        .enumerate()
246        .map(|(i, l)| format!("{:>5} │ {}", i + 1, escape(l)))
247        .collect()
248}
249
250/// A unified diff with three lines of context and old/new line numbers.
251#[must_use]
252pub fn diff(old: &str, new: &str) -> Vec<String> {
253    let d = similar::TextDiff::from_lines(old, new);
254    let mut out = Vec::new();
255    for (i, group) in d.grouped_ops(3).iter().enumerate() {
256        if i > 0 {
257            out.push("          ┈".to_owned());
258        }
259        for op in group {
260            for change in d.iter_changes(op) {
261                let (sign, old_no, new_no) = match change.tag() {
262                    similar::ChangeTag::Equal => (' ', change.old_index(), change.new_index()),
263                    similar::ChangeTag::Delete => ('-', change.old_index(), None),
264                    similar::ChangeTag::Insert => ('+', None, change.new_index()),
265                };
266                let no = |n: Option<usize>| n.map_or(String::new(), |n| (n + 1).to_string());
267                let line = format!(
268                    "{:>5} {:>5} {sign} {}",
269                    no(old_no),
270                    no(new_no),
271                    escape(change.value().trim_end_matches(['\n', '\r']))
272                );
273                out.push(line.trim_end().to_owned());
274            }
275        }
276    }
277    out
278}
279
280/// The text of `path` under `root`, or why it cannot be shown.
281fn read(context: &PresentContext, path: &str) -> Result<Option<String>, String> {
282    if let Some(text) = &context.file_text {
283        return Ok(Some(text.clone()));
284    }
285    let Some(root) = context.root.as_deref() else {
286        return Ok(None);
287    };
288    let file = root.join(path);
289    let Ok(bytes) = std::fs::read(&file) else {
290        return Ok(None);
291    };
292    if bytes.len() > MAX_BYTES {
293        return Err(format!("{} bytes — too large to show", bytes.len()));
294    }
295    String::from_utf8(bytes)
296        .map(Some)
297        .map_err(|_| "a binary file — not shown".to_owned())
298}
299
300/// Builds what the person sees.
301#[must_use]
302pub fn present(request: &Request, context: &PresentContext) -> Presentation {
303    let args = &request.arguments;
304    let s = |k: &str| args[k].as_str().unwrap_or_default().to_owned();
305    let path = s("path");
306    let kind = request.kind.as_str();
307    let mut old_text = None;
308    let mut new_text = None;
309    let (title, subject, full) = match kind {
310        "run" | "network" => {
311            let argv: Vec<String> = args["argv"]
312                .as_array()
313                .into_iter()
314                .flatten()
315                .filter_map(|v| v.as_str().map(str::to_owned))
316                .collect();
317            let cmd = escape(&shell_quote(&argv));
318            let cwd = args["cwd"]
319                .as_str()
320                .filter(|c| !c.is_empty())
321                .unwrap_or(".");
322            let net = if kind == "network" {
323                "this call".to_owned()
324            } else {
325                context.network.clone()
326            };
327            (
328                if kind == "network" {
329                    "Use the network?"
330                } else {
331                    "Run this command?"
332                },
333                cmd.clone(),
334                vec![format!("$ {cmd}"), format!("cwd: {cwd} · network: {net}")],
335            )
336        }
337        "mcp" => {
338            let pretty = serde_json::to_string_pretty(args).unwrap_or_default();
339            (
340                "Call MCP tool?",
341                request.tool.clone(),
342                pretty.lines().map(escape).collect(),
343            )
344        }
345        // An external agent's own call speccoder cannot classify: what the
346        // agent said about it, as it said it.
347        "agent" | "reads" => {
348            let pretty = serde_json::to_string_pretty(&args["rawInput"]).unwrap_or_default();
349            let mut lines = vec![format!(
350                "kind: {}",
351                escape(args["kind"].as_str().unwrap_or("not said"))
352            )];
353            lines.extend(pretty.lines().map(escape));
354            (
355                "Let the agent make this call?",
356                escape(args["title"].as_str().unwrap_or("an untitled call")),
357                lines,
358            )
359        }
360        _ => {
361            let current = read(context, &path);
362            // A write over a file that exists now is an edit of it.
363            let title = if kind == "write" && matches!(current, Ok(None)) {
364                "Write this file?"
365            } else {
366                "Edit this file?"
367            };
368            let lines = match current {
369                Err(note) => vec![note],
370                Ok(current) => match (request.tool.as_str(), current) {
371                    ("write_file", None) => {
372                        let content = s("content");
373                        new_text = Some(content.clone());
374                        numbered(&content)
375                    }
376                    ("write_file", Some(old)) => {
377                        let content = s("content");
378                        let d = diff(&old, &content);
379                        old_text = Some(old);
380                        new_text = Some(content);
381                        d
382                    }
383                    (_, Some(old)) => {
384                        let (from, to) = (s("old_string"), s("new_string"));
385                        let all = args["replace_all"] == true;
386                        if !from.is_empty() && old.contains(&from) {
387                            let new = if all {
388                                old.replace(&from, &to)
389                            } else {
390                                old.replacen(&from, &to, 1)
391                            };
392                            let d = diff(&old, &new);
393                            old_text = Some(old);
394                            new_text = Some(new);
395                            d
396                        } else {
397                            let mut l = vec![
398                                "the text to replace is not in the file as it is now:".to_owned(),
399                            ];
400                            l.extend(from.lines().map(|x| format!("  - {}", escape(x))));
401                            l.extend(to.lines().map(|x| format!("  + {}", escape(x))));
402                            l
403                        }
404                    }
405                    (_, None) => {
406                        let (from, to) = (s("old_string"), s("new_string"));
407                        let mut l: Vec<String> =
408                            from.lines().map(|x| format!("  - {}", escape(x))).collect();
409                        l.extend(to.lines().map(|x| format!("  + {}", escape(x))));
410                        l
411                    }
412                },
413            };
414            (title, escape(&path), lines)
415        }
416    };
417    let (mut body, more) = head(&full);
418    if more > 0 {
419        body.push(format!("… {more} more lines (v to view all)"));
420    }
421    let sandbox = match kind {
422        "run" | "network" => match &context.sandbox {
423            Some(m) => format!(
424                "sandbox on ({m}) · network: {} · writes: worktree",
425                if kind == "network" {
426                    "this call"
427                } else {
428                    context.network.as_str()
429                }
430            ),
431            None => "sandbox off — runs unconfined".to_owned(),
432        },
433        "mcp" => "the MCP server runs outside speccoder's sandbox".to_owned(),
434        "agent" | "reads" => {
435            "the agent's own call — it runs outside speccoder's sandbox".to_owned()
436        }
437        _ => "a file write — nothing is executed".to_owned(),
438    };
439    let scope = scope_text(kind).to_owned();
440    let task = request.task.rsplit('/').next().unwrap_or(&request.task);
441    let options = request
442        .options
443        .iter()
444        .filter_map(|o| choice(o, task, &scope))
445        .collect();
446    Presentation {
447        request: request.clone(),
448        title: title.to_owned(),
449        subject,
450        object: object_lines(request.object.as_ref(), request.changed.as_deref()),
451        sources: source_lines(request),
452        notice: budget_notice(request),
453        body,
454        full,
455        sandbox,
456        options,
457        scope,
458        old_text,
459        new_text,
460        file: context
461            .root
462            .as_ref()
463            .filter(|_| !path.is_empty() && matches!(kind, "write" | "edit"))
464            .map(|r| r.join(&path).display().to_string()),
465    }
466}
467
468/// The text a terminal shows for a bug's reproductions: each with its
469/// statement, file, test and how it failed, then the digest the answer is
470/// bound to.
471#[must_use]
472pub fn confirm_text(r: &super::ConfirmRequest) -> String {
473    let mut out = format!(
474        "Bug {} — {}: {} reproduction(s) fail on the untouched code by an assertion.\n",
475        r.bug,
476        r.title,
477        r.reproductions
478            .iter()
479            .filter(|x| x.statement.starts_with('E'))
480            .count()
481    );
482    for x in &r.reproductions {
483        out.push_str(&format!(
484            "\n{} — {}\n  {} :: {}\n  $ {}\n",
485            x.statement,
486            x.words,
487            x.file,
488            x.test,
489            x.argv.join(" ")
490        ));
491        for l in x.excerpt.lines().take(8) {
492            out.push_str(&format!("    | {l}\n"));
493        }
494    }
495    if let Some(w) = &r.warning {
496        out.push_str(&format!("\n! {w}\n"));
497    }
498    out.push_str(&format!("\ndigest {}\n", r.digest));
499    out
500}
501
502/// The choice `o` as every interface offers it: its key, its fixed number
503/// and its label.
504#[must_use]
505pub fn choice(o: &str, task: &str, scope: &str) -> Option<Choice> {
506    let (key, number, label) = match o {
507        "once" => ('y', 1, "Yes, once".to_owned()),
508        "task" => ('t', 2, format!("Yes, for the rest of {task}: {scope}")),
509        "run" => ('r', 3, format!("Yes, for this run: {scope}, in any task")),
510        "always" => (
511            'a',
512            4,
513            "Always allow exactly this command (adds it to speccoder.toml)".to_owned(),
514        ),
515        "reject" => ('n', 5, "No, and tell the model why …".to_owned()),
516        "stop" => (
517            's',
518            6,
519            "Stop this task here (it waits; decide later)".to_owned(),
520        ),
521        _ => return None,
522    };
523    Some(Choice {
524        choice: o.to_owned(),
525        key,
526        number,
527        label,
528    })
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use serde_json::json;
535    use std::path::Path;
536
537    fn request(tool: &str, arguments: serde_json::Value, kind: &str, risk: &str) -> Request {
538        Request {
539            task: "001-slugify/T004".to_owned(),
540            task_title: "Handle Unicode input".to_owned(),
541            turn: 8,
542            tool: tool.to_owned(),
543            arguments,
544            kind: kind.to_owned(),
545            reason: "why".to_owned(),
546            risk: risk.to_owned(),
547            options: crate::approvals::options(risk, true, false, kind, risk),
548            ..Request::default()
549        }
550    }
551
552    fn ctx(root: &Path) -> PresentContext {
553        PresentContext {
554            root: Some(root.to_path_buf()),
555            sandbox: Some("Seatbelt".to_owned()),
556            network: "none".to_owned(),
557            file_text: None,
558        }
559    }
560
561    #[test]
562    fn a_new_file_shows_its_head() {
563        let d = tempfile::tempdir().unwrap();
564        let content: String = (1..=25).map(|i| format!("line {i}\n")).collect();
565        let p = present(
566            &request(
567                "write_file",
568                json!({ "path": "src/new.rs", "content": content }),
569                "write",
570                "low",
571            ),
572            &ctx(d.path()),
573        );
574        assert_eq!(
575            (p.title.as_str(), p.subject.as_str()),
576            ("Write this file?", "src/new.rs")
577        );
578        assert_eq!(p.body[0], "    1 │ line 1");
579        assert_eq!(p.body.last().unwrap(), "… 5 more lines (v to view all)");
580        assert_eq!(p.full.len(), 25);
581        assert_eq!(p.sandbox, "a file write — nothing is executed");
582        assert!(p.old_text.is_none() && p.new_text.is_some());
583    }
584
585    #[test]
586    fn an_edit_is_a_numbered_diff() {
587        let d = tempfile::tempdir().unwrap();
588        std::fs::write(
589            d.path().join("Cargo.toml"),
590            "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n",
591        )
592        .unwrap();
593        let p = present(
594            &request(
595                "edit_file",
596                json!({ "path": "Cargo.toml", "old_string": "serde = \"1\"\n", "new_string": "serde = \"1\"\nunicode-normalization = \"0.1\"\n" }),
597                "edit",
598                "medium",
599            ),
600            &ctx(d.path()),
601        );
602        assert_eq!(p.title, "Edit this file?");
603        assert!(
604            p.body
605                .contains(&"          6 + unicode-normalization = \"0.1\"".to_owned()),
606            "{:#?}",
607            p.body
608        );
609        assert!(
610            p.body.contains(&"    5     5   serde = \"1\"".to_owned()),
611            "{:#?}",
612            p.body
613        );
614        let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect();
615        assert_eq!(
616            labels[1],
617            "Yes, for the rest of T004: this file, any content"
618        );
619        assert_eq!(
620            labels[2],
621            "Yes, for this run: this file, any content, in any task"
622        );
623    }
624
625    #[test]
626    fn a_missing_old_string_is_said_and_shown() {
627        let d = tempfile::tempdir().unwrap();
628        std::fs::write(d.path().join("a.rs"), "fn a() {}\n").unwrap();
629        let p = present(
630            &request(
631                "edit_file",
632                json!({ "path": "a.rs", "old_string": "fn b() {}", "new_string": "fn c() {}" }),
633                "edit",
634                "low",
635            ),
636            &ctx(d.path()),
637        );
638        assert_eq!(
639            p.body[0],
640            "the text to replace is not in the file as it is now:"
641        );
642        assert_eq!(
643            p.body[1..],
644            ["  - fn b() {}".to_owned(), "  + fn c() {}".to_owned()]
645        );
646    }
647
648    #[test]
649    fn commands_network_and_mcp() {
650        let d = tempfile::tempdir().unwrap();
651        let p = present(
652            &request(
653                "run_command",
654                json!({ "argv": ["sh", "-c", "echo 'hi' > x"] }),
655                "run",
656                "high",
657            ),
658            &ctx(d.path()),
659        );
660        assert_eq!(p.title, "Run this command?");
661        assert_eq!(
662            p.body,
663            ["$ sh -c 'echo '\\''hi'\\'' > x'", "cwd: . · network: none"]
664        );
665        assert_eq!(
666            p.sandbox,
667            "sandbox on (Seatbelt) · network: none · writes: worktree"
668        );
669        // High risk: no "for this run", no "always"; the numbers stay fixed.
670        let numbers: Vec<u8> = p.options.iter().map(|o| o.number).collect();
671        assert_eq!(numbers, [1, 2, 5, 6]);
672        let p = present(
673            &request(
674                "run_command",
675                json!({ "argv": ["curl", "x"], "network": true }),
676                "network",
677                "high",
678            ),
679            &PresentContext {
680                sandbox: None,
681                ..ctx(d.path())
682            },
683        );
684        assert_eq!(p.title, "Use the network?");
685        assert_eq!(p.body[1], "cwd: . · network: this call");
686        assert_eq!(p.sandbox, "sandbox off — runs unconfined");
687        let p = present(
688            &request(
689                "mcp__gh__create_issue",
690                json!({ "title": "x" }),
691                "mcp",
692                "medium",
693            ),
694            &ctx(d.path()),
695        );
696        assert_eq!(
697            (p.title.as_str(), p.subject.as_str()),
698            ("Call MCP tool?", "mcp__gh__create_issue")
699        );
700        assert_eq!(p.sandbox, "the MCP server runs outside speccoder's sandbox");
701        assert!(
702            p.options[1]
703                .label
704                .ends_with("this tool as defined now, any arguments")
705        );
706    }
707
708    #[test]
709    fn model_text_cannot_drive_the_terminal() {
710        assert_eq!(escape("a\x1b[2Jb\x07c\nd"), "a␛[2Jb^Gc\nd");
711        let d = tempfile::tempdir().unwrap();
712        std::fs::write(d.path().join("bin"), [0u8, 159, 146, 150]).unwrap();
713        let p = present(
714            &request(
715                "write_file",
716                json!({ "path": "bin", "content": "x" }),
717                "edit",
718                "low",
719            ),
720            &ctx(d.path()),
721        );
722        assert_eq!(p.body, ["a binary file — not shown"]);
723    }
724
725    #[test]
726    fn the_object_sources_and_budget_are_shown() {
727        let d = tempfile::tempdir().unwrap();
728        let mut r = request(
729            "run_command",
730            json!({ "argv": ["make", "check"] }),
731            "run",
732            "low",
733        );
734        r.object = Some(Object::command(
735            vec!["make".to_owned(), "check".to_owned()],
736            ".".to_owned(),
737            "/w/bin/make".to_owned(),
738            false,
739        ));
740        r.changed = Some("executable /usr/bin/make → /w/bin/make".to_owned());
741        r.sources = vec![crate::approvals::Source {
742            argument: "make check".to_owned(),
743            from: "README.md".to_owned(),
744            turn: 2,
745        }];
746        r.more = 2;
747        r.asked = 12;
748        r.budget = 12;
749        r.options = crate::approvals::options("low", true, false, "run", "low");
750        let p = present(&r, &ctx(d.path()));
751        assert_eq!(
752            p.object[0],
753            format!(
754                "argv  [\"make\",\"check\"]  #{}",
755                &crate::tools::policy::argv_sha256(&["make".to_owned(), "check".to_owned()])[..12]
756            )
757        );
758        assert_eq!(p.object[1], "runs  /w/bin/make  in  .");
759        assert_eq!(
760            p.object[2],
761            "changed since approved: executable /usr/bin/make → /w/bin/make"
762        );
763        assert_eq!(
764            p.sources,
765            [
766                "contains text read from README.md (turn 2): \"make check\"",
767                "and 2 more"
768            ]
769        );
770        assert!(
771            p.notice
772                .as_deref()
773                .is_some_and(|n| n.starts_with("This is prompt 12 of 12"))
774        );
775        let keys: Vec<char> = p.options.iter().map(|o| o.key).collect();
776        assert_eq!(keys, ['y', 't', 'r', 'a', 'n', 's']);
777        // A path asked by another name shows both.
778        let mut w = request(
779            "write_file",
780            json!({ "path": "src/a/x.rs", "content": "" }),
781            "write",
782            "low",
783        );
784        w.object = Some(Object::Path {
785            canonical: "src/b/x.rs".to_owned(),
786            asked: "src/a/x.rs".to_owned(),
787        });
788        let p = present(&w, &ctx(d.path()));
789        assert_eq!(
790            p.object,
791            [
792                "file  src/b/x.rs",
793                "asked as  src/a/x.rs (resolves to the file above)"
794            ]
795        );
796    }
797}