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