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