Skip to main content

ag_agent/agent/
prompt.rs

1//! Shared prompt-shaping helpers for agent-facing markdown prompts.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub use ag_protocol::ProtocolSchemaInstructionMode;
7use ag_protocol::{
8    ProtocolRequestProfile, prepend_protocol_instructions as protocol_prepend_instructions,
9    prepend_protocol_refresh_reminder as protocol_prepend_refresh_reminder,
10};
11use askama::Template;
12
13use super::backend::{AgentBackendError, BuildCommandRequest};
14use super::instruction::InstructionDeliveryMode;
15use crate::model::turn_prompt::{
16    TurnPromptAttachment, TurnPromptContentPart, split_turn_prompt_content,
17};
18
19/// Askama view model for rendering resume prompts with prior session output.
20#[derive(Template)]
21#[template(path = "resume_with_session_output_prompt.md", escape = "none")]
22struct ResumeWithSessionOutputPromptTemplate<'a> {
23    /// New prompt content appended after the replayed transcript.
24    prompt: &'a str,
25    /// Prior session output replayed into the follow-up prompt.
26    session_output: &'a str,
27}
28
29/// Shared prompt preparation input for one transport turn.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct PromptPreparationRequest<'a> {
32    /// Delivery mode selected for the current provider attempt.
33    pub instruction_delivery_mode: InstructionDeliveryMode,
34    /// Base user prompt before replay wrapping and protocol instructions.
35    pub prompt: &'a str,
36    /// Protocol family that determines the rendered instruction envelope.
37    pub protocol_profile: ProtocolRequestProfile,
38    /// Prior session output available for transcript replay.
39    pub replay_session_output: Option<&'a str>,
40    /// Schema guidance mode selected from the provider's structured-output
41    /// capability.
42    pub schema_instruction_mode: ProtocolSchemaInstructionMode,
43}
44
45/// Controls which directories CLI prompt transports expose as filesystem access
46/// roots.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) enum CliPromptAccessRootMode {
49    /// Expose only attachment parent directories.
50    AttachmentsOnly,
51    /// Expose the workspace folder first, then attachment parent directories.
52    WorkspaceThenAttachments,
53}
54
55/// Applies transcript replay and protocol instructions to one prompt.
56///
57/// # Errors
58/// Returns an error when replay or instruction templates fail to render.
59pub fn prepare_prompt_text(
60    request: PromptPreparationRequest<'_>,
61) -> Result<String, AgentBackendError> {
62    match request.instruction_delivery_mode {
63        InstructionDeliveryMode::BootstrapFull => Ok(protocol_prepend_instructions(
64            request.prompt,
65            request.protocol_profile,
66            request.schema_instruction_mode,
67        )),
68        InstructionDeliveryMode::DeltaOnly => Ok(protocol_prepend_refresh_reminder(
69            request.prompt,
70            request.protocol_profile,
71        )),
72        InstructionDeliveryMode::BootstrapWithReplay => {
73            let prompt = build_resume_prompt(request.prompt, request.replay_session_output)?;
74
75            Ok(protocol_prepend_instructions(
76                &prompt,
77                request.protocol_profile,
78                request.schema_instruction_mode,
79            ))
80        }
81    }
82}
83
84/// Builds a resume prompt that optionally prepends previous session output.
85///
86/// # Errors
87/// Returns an error if Askama template rendering fails.
88pub(crate) fn build_resume_prompt(
89    prompt: &str,
90    session_output: Option<&str>,
91) -> Result<String, AgentBackendError> {
92    let Some(session_output) = session_output
93        .map(str::trim)
94        .filter(|value| !value.is_empty())
95    else {
96        return Ok(prompt.to_string());
97    };
98
99    let template = ResumeWithSessionOutputPromptTemplate {
100        prompt,
101        session_output,
102    };
103
104    render_template("resume_with_session_output_prompt.md", &template)
105}
106
107/// Builds a full prompt payload to stream over stdin for CLI providers.
108///
109/// This shared helper keeps attachment placeholder rendering and provider
110/// protocol preparation in one place while preserving backend-specific error
111/// labels.
112///
113/// # Errors
114/// Returns an error when attachment path rendering, resume wrapping, or
115/// protocol prompt rendering fails.
116pub(crate) fn build_prompt_stdin_payload(
117    request: BuildCommandRequest<'_>,
118    schema_instruction_mode: ProtocolSchemaInstructionMode,
119    backend_display_name: &str,
120) -> Result<Vec<u8>, AgentBackendError> {
121    let prompt =
122        render_prompt_with_local_images(request.prompt, request.attachments, backend_display_name)?;
123    let prompt = prepare_prompt_text(PromptPreparationRequest {
124        instruction_delivery_mode: if request.request_kind.is_resume() {
125            InstructionDeliveryMode::BootstrapWithReplay
126        } else {
127            InstructionDeliveryMode::BootstrapFull
128        },
129        prompt: &prompt,
130        protocol_profile: request.request_kind.protocol_profile(),
131        replay_session_output: request.request_kind.session_output(),
132        schema_instruction_mode,
133    })?;
134
135    Ok(prompt.into_bytes())
136}
137
138/// Appends CLI prompt filesystem access roots as `--add-dir` arguments.
139///
140/// Claude only needs pasted-image parent directories because its process
141/// working directory is already the session workspace. Antigravity derives its
142/// editable workspace from ordered `--add-dir` roots, so it uses
143/// [`CliPromptAccessRootMode::WorkspaceThenAttachments`] to keep the workspace
144/// root first.
145pub(crate) fn append_cli_prompt_access_directories(
146    command: &mut Command,
147    workspace_folder: &Path,
148    attachments: &[TurnPromptAttachment],
149    root_mode: CliPromptAccessRootMode,
150) {
151    for directory in cli_prompt_access_directories(workspace_folder, attachments, root_mode) {
152        command.arg("--add-dir").arg(directory);
153    }
154}
155
156/// Replaces inline image placeholders with provider-usable local image paths.
157///
158/// The function preserves attachment ordering through prompt content parsing
159/// and appends any orphaned attachments that no longer have a placeholder in
160/// the prompt text.
161///
162/// # Errors
163/// Returns an error when any local image path is not valid UTF-8.
164pub(crate) fn render_prompt_with_local_images(
165    prompt: &str,
166    attachments: &[TurnPromptAttachment],
167    backend_display_name: &str,
168) -> Result<String, AgentBackendError> {
169    if attachments.is_empty() {
170        return Ok(prompt.to_string());
171    }
172
173    let mut rendered_prompt = String::new();
174
175    for content_part in split_turn_prompt_content(prompt, attachments) {
176        match content_part {
177            TurnPromptContentPart::Text(text) => rendered_prompt.push_str(text),
178            TurnPromptContentPart::Attachment(attachment) => {
179                let attachment_path = attachment_path_for_prompt(backend_display_name, attachment)?;
180                rendered_prompt.push_str(&attachment_path);
181            }
182            TurnPromptContentPart::OrphanAttachment(attachment) => {
183                if !rendered_prompt.is_empty()
184                    && rendered_prompt
185                        .chars()
186                        .last()
187                        .is_some_and(|character| !character.is_whitespace())
188                {
189                    rendered_prompt.push('\n');
190                }
191
192                rendered_prompt.push_str(&attachment_path_for_prompt(
193                    backend_display_name,
194                    attachment,
195                )?);
196                rendered_prompt.push('\n');
197            }
198        }
199    }
200
201    Ok(rendered_prompt)
202}
203
204/// Returns ordered filesystem access roots for CLI prompt image access.
205///
206/// Directory paths are deduplicated and sorted for deterministic subprocess
207/// argument ordering. When `root_mode` requests the workspace, the session
208/// folder appears before attachment directories and is never duplicated.
209pub(crate) fn cli_prompt_access_directories(
210    workspace_folder: &Path,
211    attachments: &[TurnPromptAttachment],
212    root_mode: CliPromptAccessRootMode,
213) -> Vec<PathBuf> {
214    let mut attachment_directories = attachments
215        .iter()
216        .filter_map(|attachment| attachment.local_image_path.parent())
217        .map(ToOwned::to_owned)
218        .collect::<Vec<_>>();
219    attachment_directories.sort();
220    attachment_directories.dedup();
221
222    if matches!(root_mode, CliPromptAccessRootMode::AttachmentsOnly) {
223        return attachment_directories;
224    }
225
226    attachment_directories
227        .retain(|attachment_directory| attachment_directory.as_path() != workspace_folder);
228
229    let mut workspace_directories = Vec::with_capacity(attachment_directories.len() + 1);
230    workspace_directories.push(workspace_folder.to_path_buf());
231    workspace_directories.extend(attachment_directories);
232
233    workspace_directories
234}
235
236/// Returns one attachment path for prompt injection as strict UTF-8 text.
237///
238/// # Errors
239/// Returns an error when the attachment path cannot be represented as UTF-8.
240fn attachment_path_for_prompt(
241    backend_display_name: &str,
242    attachment: &TurnPromptAttachment,
243) -> Result<String, AgentBackendError> {
244    attachment
245        .local_image_path
246        .to_str()
247        .map(ToOwned::to_owned)
248        .ok_or_else(|| {
249            AgentBackendError::CommandBuild(format!(
250                "{backend_display_name} prompt image path is not valid UTF-8"
251            ))
252        })
253}
254
255/// Builds a Markdown code-fence delimiter long enough to safely wrap an
256/// arbitrary prompt payload.
257///
258/// Returns a string of backticks whose length exceeds the longest run of
259/// consecutive backticks found anywhere in `content`, with a minimum length
260/// of three. This prevents a triple-backtick fence from being terminated
261/// prematurely when the payload itself contains Markdown fences (for example,
262/// when reviewing changes to Markdown or prompt-template files).
263pub fn diff_fence(content: &str) -> String {
264    let mut max_run = 0usize;
265    let mut current_run = 0usize;
266    for character in content.chars() {
267        if character == '`' {
268            current_run += 1;
269            if current_run > max_run {
270                max_run = current_run;
271            }
272        } else {
273            current_run = 0;
274        }
275    }
276
277    let fence_length = std::cmp::max(3, max_run + 1);
278
279    "`".repeat(fence_length)
280}
281
282/// Renders one Askama markdown template and trims the trailing newline added
283/// by file-based templates.
284fn render_template(
285    template_name: &str,
286    template: &impl Template,
287) -> Result<String, AgentBackendError> {
288    let rendered = template.render().map_err(|error| {
289        AgentBackendError::CommandBuild(format!("Failed to render `{template_name}`: {error}"))
290    })?;
291
292    Ok(rendered.trim_end().to_string())
293}
294
295#[cfg(test)]
296mod tests {
297    #[cfg(unix)]
298    use std::ffi::OsString;
299    #[cfg(unix)]
300    use std::os::unix::ffi::OsStringExt;
301    use std::path::PathBuf;
302
303    use super::*;
304
305    #[test]
306    /// Ensures the diff fence falls back to three backticks when the content
307    /// contains no backtick runs.
308    fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
309        // Arrange
310        let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
311
312        // Act
313        let fence = diff_fence(diff);
314
315        // Assert
316        assert_eq!(fence, "```");
317    }
318
319    #[test]
320    /// Ensures the diff fence grows to exceed the longest backtick run in the
321    /// diff so a Markdown triple-backtick fence inside the diff cannot
322    /// terminate the outer wrapper fence.
323    fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
324        // Arrange
325        let diff = "+```\nsample\n+```\n";
326
327        // Act
328        let fence = diff_fence(diff);
329
330        // Assert
331        assert_eq!(fence, "````");
332    }
333
334    #[test]
335    /// Ensures longer backtick runs keep producing a strictly longer fence so
336    /// nested or unusually long code fences in the diff stay contained.
337    fn test_diff_fence_handles_long_backtick_runs() {
338        // Arrange
339        let diff = "prefix `````diff\ncontent\n`````\n";
340
341        // Act
342        let fence = diff_fence(diff);
343
344        // Assert
345        assert_eq!(fence, "``````");
346    }
347
348    #[test]
349    /// Ensures resume prompt rendering includes trimmed session output and
350    /// the new user prompt.
351    fn test_build_resume_prompt_includes_session_output_and_prompt() {
352        // Arrange
353        let prompt = "Continue and update tests";
354        let session_output = Some("  previous output line  \n");
355
356        // Act
357        let resume_prompt =
358            build_resume_prompt(prompt, session_output).expect("resume prompt should render");
359
360        // Assert
361        let normalized_resume_prompt = resume_prompt.split_whitespace().collect::<Vec<_>>();
362        let normalized_resume_prompt = normalized_resume_prompt.join(" ");
363        assert!(resume_prompt.contains("previous output line"));
364        assert!(normalized_resume_prompt.contains("Treat the user's new prompt as a follow-up"));
365        assert!(normalized_resume_prompt.contains("changes made during this Agentty session"));
366        assert!(normalized_resume_prompt.contains("preserve unrelated pre-existing work"));
367        assert!(resume_prompt.contains("Continue and update tests"));
368    }
369
370    #[test]
371    /// Ensures whitespace-only session output does not trigger transcript
372    /// wrapping and returns the original prompt.
373    fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
374        // Arrange
375        let prompt = "Follow-up request";
376        let session_output = Some("   ");
377
378        // Act
379        let resume_prompt =
380            build_resume_prompt(prompt, session_output).expect("resume prompt should render");
381
382        // Assert
383        assert_eq!(resume_prompt, prompt);
384    }
385
386    #[test]
387    /// Ensures absent session output keeps resume prompt formatting unchanged.
388    fn test_build_resume_prompt_returns_original_prompt_without_output() {
389        // Arrange
390        let prompt = "Retry merge";
391
392        // Act
393        let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
394
395        // Assert
396        assert_eq!(resume_prompt, prompt);
397    }
398
399    #[test]
400    /// Ensures session prompts include the critical protocol contract markers.
401    fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
402        // Arrange
403        let prompt = "Implement feature";
404
405        // Act
406        let rendered_prompt = protocol_prepend_instructions(
407            prompt,
408            ProtocolRequestProfile::SessionTurn,
409            ProtocolSchemaInstructionMode::PromptSchema,
410        );
411
412        // Assert
413        assert!(rendered_prompt.contains("File path output requirements:"));
414        assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
415        assert!(
416            rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
417        );
418        assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
419        assert!(rendered_prompt.contains("Do not run mutating git commands"));
420        assert!(rendered_prompt.contains("Quality check requirements:"));
421        assert!(rendered_prompt.contains("repository-defined quality checks"));
422        let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
423        let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
424        assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
425        assert!(rendered_prompt.contains("full repository test/check suite"));
426        assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
427        assert!(rendered_prompt.contains("Structured response protocol:"));
428        assert!(rendered_prompt.contains("Return a single JSON object"));
429        assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
430        assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
431        assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
432        assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
433        assert!(
434            rendered_prompt
435                .contains("______________________________________________________________________")
436        );
437        assert!(!rendered_prompt.contains("{# task separator #}"));
438        assert!(rendered_prompt.contains("For this session turn"));
439        assert!(normalized_rendered_prompt.contains("Do not create commits"));
440        assert!(normalized_rendered_prompt.contains("suggest creating commits"));
441        assert!(rendered_prompt.contains("summary"));
442        assert!(rendered_prompt.contains("turn"));
443        assert!(rendered_prompt.contains("session"));
444        assert!(rendered_prompt.contains("\"answer\""));
445        assert!(rendered_prompt.contains("\"questions\""));
446        assert!(rendered_prompt.contains("\"title\""));
447        assert!(rendered_prompt.contains("\"description\""));
448        assert!(rendered_prompt.contains("summary"));
449        assert!(rendered_prompt.ends_with(prompt));
450    }
451
452    #[test]
453    /// Ensures schema-enforcing transports get protocol policy without the
454    /// large prompt-side JSON Schema body.
455    fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
456        // Arrange
457        let prompt = "Implement feature";
458
459        // Act
460        let rendered_prompt = protocol_prepend_instructions(
461            prompt,
462            ProtocolRequestProfile::SessionTurn,
463            ProtocolSchemaInstructionMode::TransportSchema,
464        );
465
466        // Assert
467        assert!(rendered_prompt.contains("Structured response protocol:"));
468        assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
469        assert!(rendered_prompt.contains("Return a single JSON object"));
470        assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
471        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
472        assert!(rendered_prompt.ends_with(prompt));
473    }
474
475    #[test]
476    /// Ensures protocol instructions are not duplicated when already present.
477    fn test_prepend_protocol_instructions_is_idempotent() {
478        // Arrange
479        let prompt = protocol_prepend_instructions(
480            "Implement feature",
481            ProtocolRequestProfile::SessionTurn,
482            ProtocolSchemaInstructionMode::PromptSchema,
483        );
484
485        // Act
486        let rendered_prompt = protocol_prepend_instructions(
487            &prompt,
488            ProtocolRequestProfile::UtilityPrompt,
489            ProtocolSchemaInstructionMode::TransportSchema,
490        );
491
492        // Assert
493        assert_eq!(rendered_prompt, prompt);
494    }
495
496    #[test]
497    /// Ensures one-shot prompts reuse the shared full-schema protocol
498    /// instructions.
499    fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
500        // Arrange
501        let prompt = "Generate title";
502
503        // Act
504        let rendered_prompt = protocol_prepend_instructions(
505            prompt,
506            ProtocolRequestProfile::UtilityPrompt,
507            ProtocolSchemaInstructionMode::PromptSchema,
508        );
509
510        // Assert
511        assert!(rendered_prompt.contains("Structured response protocol:"));
512        assert!(
513            rendered_prompt
514                .contains("______________________________________________________________________")
515        );
516        assert!(rendered_prompt.contains("For this one-shot utility prompt"));
517        assert!(rendered_prompt.contains(r#"{"answer":"...","questions":[],"summary":null}"#));
518        assert!(rendered_prompt.contains("\"summary\""));
519        assert!(rendered_prompt.ends_with(prompt));
520    }
521
522    #[test]
523    /// Ensures shared prompt preparation applies replay wrapping before
524    /// protocol instructions.
525    fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
526        // Arrange
527        let request = PromptPreparationRequest {
528            instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
529            prompt: "Continue edits",
530            protocol_profile: ProtocolRequestProfile::SessionTurn,
531            replay_session_output: Some("previous output"),
532            schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
533        };
534
535        // Act
536        let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
537
538        // Assert
539        assert!(prepared_prompt.contains("Structured response protocol:"));
540        assert!(prepared_prompt.contains("previous output"));
541        assert!(prepared_prompt.contains(r"\<user_prompt> Continue edits \</user_prompt>"));
542        assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
543    }
544
545    #[test]
546    /// Ensures compact refresh reminders omit the full schema while keeping
547    /// the contract reminder and task body.
548    fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
549        // Arrange
550        let prompt = "Continue the implementation";
551
552        // Act
553        let rendered_prompt =
554            protocol_prepend_refresh_reminder(prompt, ProtocolRequestProfile::SessionTurn);
555
556        // Assert
557        assert!(rendered_prompt.contains("Protocol refresh reminder:"));
558        assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
559        assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
560        assert!(rendered_prompt.contains("Do not run mutating git commands."));
561        assert!(
562            rendered_prompt
563                .contains("______________________________________________________________________")
564        );
565        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
566        assert!(rendered_prompt.ends_with(prompt));
567    }
568
569    #[test]
570    /// Ensures prompt preparation can emit the compact app-server reminder
571    /// instead of the full bootstrap wrapper.
572    fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
573        // Arrange
574        let request = PromptPreparationRequest {
575            instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
576            prompt: "Continue edits",
577            protocol_profile: ProtocolRequestProfile::SessionTurn,
578            replay_session_output: Some("previous output"),
579            schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
580        };
581
582        // Act
583        let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
584
585        // Assert
586        assert!(prepared_prompt.contains("Protocol refresh reminder:"));
587        assert!(!prepared_prompt.contains("Authoritative JSON Schema:"));
588        assert!(!prepared_prompt.contains("previous output"));
589        assert!(prepared_prompt.ends_with("Continue edits"));
590    }
591
592    #[test]
593    /// Ensures CLI prompt rendering replaces image placeholders with local
594    /// file paths in placeholder order.
595    fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
596        // Arrange
597        let attachments = vec![
598            TurnPromptAttachment {
599                placeholder: "[Image #1]".to_string(),
600                local_image_path: PathBuf::from("/tmp/first-image.png"),
601            },
602            TurnPromptAttachment {
603                placeholder: "[Image #2]".to_string(),
604                local_image_path: PathBuf::from("/tmp/second-image.png"),
605            },
606        ];
607
608        // Act
609        let rendered_prompt = render_prompt_with_local_images(
610            "Compare [Image #2] with [Image #1]",
611            &attachments,
612            "TestBackend",
613        )
614        .expect("prompt rendering should succeed");
615
616        // Assert
617        assert_eq!(
618            rendered_prompt,
619            "Compare /tmp/second-image.png with /tmp/first-image.png"
620        );
621    }
622
623    #[test]
624    /// Ensures CLI prompt rendering appends local image paths when attachment
625    /// metadata survives without a placeholder match.
626    fn test_render_prompt_with_local_images_appends_missing_paths() {
627        // Arrange
628        let attachments = vec![TurnPromptAttachment {
629            placeholder: "[Image #1]".to_string(),
630            local_image_path: PathBuf::from("/tmp/first-image.png"),
631        }];
632
633        // Act
634        let rendered_prompt =
635            render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
636                .expect("prompt rendering should succeed");
637
638        // Assert
639        assert_eq!(
640            rendered_prompt,
641            "Review this change\n/tmp/first-image.png\n"
642        );
643    }
644
645    #[cfg(unix)]
646    #[test]
647    /// Ensures CLI prompt rendering fails fast with the provider label when an
648    /// attachment path is not valid UTF-8.
649    fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
650        // Arrange
651        let attachments = vec![TurnPromptAttachment {
652            placeholder: "[Image #1]".to_string(),
653            local_image_path: PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f])),
654        }];
655
656        // Act
657        let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
658            .expect_err("prompt rendering should fail");
659
660        // Assert
661        assert_eq!(
662            error,
663            AgentBackendError::CommandBuild(
664                "Claude prompt image path is not valid UTF-8".to_string()
665            )
666        );
667    }
668
669    #[test]
670    /// Ensures CLI prompt access roots deduplicate sorted attachment
671    /// directories when the provider only needs attachment parents.
672    fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
673        // Arrange
674        let workspace_folder = PathBuf::from("/tmp/session");
675        let attachments = vec![
676            TurnPromptAttachment {
677                placeholder: "[Image #1]".to_string(),
678                local_image_path: PathBuf::from("/tmp/images-b/two.png"),
679            },
680            TurnPromptAttachment {
681                placeholder: "[Image #2]".to_string(),
682                local_image_path: PathBuf::from("/tmp/images-a/one.png"),
683            },
684            TurnPromptAttachment {
685                placeholder: "[Image #3]".to_string(),
686                local_image_path: PathBuf::from("/tmp/images-a/three.png"),
687            },
688        ];
689
690        // Act
691        let directories = cli_prompt_access_directories(
692            &workspace_folder,
693            &attachments,
694            CliPromptAccessRootMode::AttachmentsOnly,
695        );
696
697        // Assert
698        assert_eq!(
699            directories,
700            vec![
701                PathBuf::from("/tmp/images-a"),
702                PathBuf::from("/tmp/images-b")
703            ]
704        );
705    }
706
707    #[test]
708    /// Ensures Antigravity-style access roots keep the workspace first and do
709    /// not duplicate it when an attachment also lives under that directory.
710    fn test_cli_prompt_access_directories_keeps_workspace_first() {
711        // Arrange
712        let workspace_folder = PathBuf::from("/tmp/z-session");
713        let attachments = vec![
714            TurnPromptAttachment {
715                placeholder: "[Image #1]".to_string(),
716                local_image_path: PathBuf::from("/tmp/z-session/one.png"),
717            },
718            TurnPromptAttachment {
719                placeholder: "[Image #2]".to_string(),
720                local_image_path: PathBuf::from("/tmp/a-images/two.png"),
721            },
722        ];
723
724        // Act
725        let directories = cli_prompt_access_directories(
726            &workspace_folder,
727            &attachments,
728            CliPromptAccessRootMode::WorkspaceThenAttachments,
729        );
730
731        // Assert
732        assert_eq!(
733            directories,
734            vec![workspace_folder, PathBuf::from("/tmp/a-images")]
735        );
736    }
737}