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