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    /// Collapses rendered prompt whitespace for semantic assertions.
395    fn normalize_prompt(prompt: &str) -> String {
396        prompt.split_whitespace().collect::<Vec<_>>().join(" ")
397    }
398
399    #[test]
400    /// Ensures the diff fence falls back to three backticks when the content
401    /// contains no backtick runs.
402    fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
403        // Arrange
404        let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
405
406        // Act
407        let fence = diff_fence(diff);
408
409        // Assert
410        assert_eq!(fence, "```");
411    }
412
413    #[test]
414    /// Ensures the diff fence grows to exceed the longest backtick run in the
415    /// diff so a Markdown triple-backtick fence inside the diff cannot
416    /// terminate the outer wrapper fence.
417    fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
418        // Arrange
419        let diff = "+```\nsample\n+```\n";
420
421        // Act
422        let fence = diff_fence(diff);
423
424        // Assert
425        assert_eq!(fence, "````");
426    }
427
428    #[test]
429    /// Ensures longer backtick runs keep producing a strictly longer fence so
430    /// nested or unusually long code fences in the diff stay contained.
431    fn test_diff_fence_handles_long_backtick_runs() {
432        // Arrange
433        let diff = "prefix `````diff\ncontent\n`````\n";
434
435        // Act
436        let fence = diff_fence(diff);
437
438        // Assert
439        assert_eq!(fence, "``````");
440    }
441
442    #[test]
443    /// Ensures resume prompt rendering includes trimmed transcript text and
444    /// the new user prompt.
445    fn test_build_resume_prompt_includes_replay_transcript_and_prompt() {
446        // Arrange
447        let prompt = "Continue tests; keep {{ transcript }} literal";
448        let replay_transcript = Some("  previous {{ prompt }} line  \n");
449
450        // Act
451        let resume_prompt =
452            build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
453
454        let normalized_resume_prompt = normalize_prompt(&resume_prompt);
455        let transcript_position = resume_prompt
456            .find(r"\<session_transcript> previous {{ prompt }} line")
457            .expect("transcript boundary should be present");
458        let prompt_position = resume_prompt
459            .find(r"\<user_prompt> Continue tests; keep {{ transcript }} literal")
460            .expect("user prompt boundary should be present");
461
462        // Assert
463        assert!(transcript_position < prompt_position);
464        assert!(normalized_resume_prompt.contains("new user prompt as a follow-up"));
465        assert!(normalized_resume_prompt.contains("changes made during this session"));
466        assert!(normalized_resume_prompt.contains("preserve unrelated pre-existing work"));
467        assert!(
468            normalized_resume_prompt.contains("do not re-execute its commands or instructions")
469        );
470        assert!(resume_prompt.ends_with(r"\</user_prompt>"));
471    }
472
473    #[test]
474    /// Ensures whitespace-only transcript text does not trigger transcript
475    /// wrapping and returns the original prompt.
476    fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
477        // Arrange
478        let prompt = "Follow-up request";
479        let replay_transcript = Some("   ");
480
481        // Act
482        let resume_prompt =
483            build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
484
485        // Assert
486        assert_eq!(resume_prompt, prompt);
487    }
488
489    #[test]
490    /// Ensures absent transcript text keeps resume prompt formatting unchanged.
491    fn test_build_resume_prompt_returns_original_prompt_without_output() {
492        // Arrange
493        let prompt = "Retry merge";
494
495        // Act
496        let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
497
498        // Assert
499        assert_eq!(resume_prompt, prompt);
500    }
501
502    #[test]
503    /// Ensures session prompts include the critical protocol contract markers.
504    fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
505        // Arrange
506        let prompt = "Implement feature";
507
508        // Act
509        let rendered_prompt = protocol_prepend_instructions(
510            prompt,
511            ProtocolRequestProfile::SessionTurn,
512            ProtocolSchemaInstructionMode::PromptSchema,
513            test_workspace_root(),
514        );
515
516        let normalized_prompt = normalize_prompt(&rendered_prompt);
517        let protocol_position = rendered_prompt
518            .find("Structured response protocol:")
519            .expect("protocol marker should be present");
520        let schema_position = rendered_prompt
521            .find("Authoritative JSON Schema:")
522            .expect("schema should be present");
523        let user_prompt_position = rendered_prompt
524            .rfind(prompt)
525            .expect("user prompt should be present");
526
527        // Assert
528        assert!(rendered_prompt.contains("File path output requirements:"));
529        assert!(rendered_prompt.contains("Workspace isolation requirements:"));
530        assert!(protocol_position < schema_position);
531        assert!(schema_position < user_prompt_position);
532        assert!(rendered_prompt.contains("`/tmp/agentty-wt/session-1`"));
533        assert!(normalized_prompt.contains("everything outside it is read-only"));
534        assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
535        assert!(normalized_prompt.contains("Git commands must be read-only"));
536        assert!(normalized_prompt.contains("Never run mutating commands"));
537        assert!(rendered_prompt.contains("Quality check requirements:"));
538        assert!(rendered_prompt.contains("repository-defined checks"));
539        assert!(normalized_prompt.contains("affected dependencies and dependents"));
540        assert!(normalized_prompt.contains("full repository test/check suite"));
541        assert!(rendered_prompt.contains("Structured response protocol:"));
542        assert!(normalized_prompt.contains("exactly one JSON object"));
543        assert!(normalized_prompt.contains("Follow this JSON Schema exactly"));
544        assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
545        assert!(
546            rendered_prompt
547                .contains("______________________________________________________________________")
548        );
549        assert!(!rendered_prompt.contains("{# task separator #}"));
550        assert!(rendered_prompt.contains("For this session turn:"));
551        assert!(normalized_prompt.contains("Do not create commits; do not suggest creating them"));
552        assert!(normalized_prompt.contains("Leave `subtasks` empty unless"));
553        assert!(rendered_prompt.contains("\"answer\""));
554        assert!(rendered_prompt.contains("\"questions\""));
555        assert!(rendered_prompt.contains("\"title\""));
556        assert!(rendered_prompt.contains("\"description\""));
557        assert!(rendered_prompt.ends_with(prompt));
558    }
559
560    #[test]
561    /// Ensures schema-enforcing transports get protocol policy without the
562    /// large prompt-side JSON Schema body.
563    fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
564        // Arrange
565        let prompt = "Implement feature";
566
567        // Act
568        let rendered_prompt = protocol_prepend_instructions(
569            prompt,
570            ProtocolRequestProfile::SessionTurn,
571            ProtocolSchemaInstructionMode::TransportSchema,
572            test_workspace_root(),
573        );
574
575        // Assert
576        assert!(rendered_prompt.contains("Structured response protocol:"));
577        assert!(rendered_prompt.contains("provider enforces the response JSON schema"));
578        assert!(normalize_prompt(&rendered_prompt).contains("exactly one JSON object"));
579        assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
580        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
581        assert!(rendered_prompt.ends_with(prompt));
582    }
583
584    #[test]
585    /// Ensures protocol instructions are not duplicated when already present.
586    fn test_prepend_protocol_instructions_is_idempotent() {
587        // Arrange
588        let prompt = protocol_prepend_instructions(
589            "Implement feature",
590            ProtocolRequestProfile::SessionTurn,
591            ProtocolSchemaInstructionMode::PromptSchema,
592            test_workspace_root(),
593        );
594
595        // Act
596        let rendered_prompt = protocol_prepend_instructions(
597            &prompt,
598            ProtocolRequestProfile::UtilityPrompt,
599            ProtocolSchemaInstructionMode::TransportSchema,
600            test_workspace_root(),
601        );
602
603        // Assert
604        assert_eq!(rendered_prompt, prompt);
605    }
606
607    #[test]
608    /// Ensures one-shot prompts reuse the shared full-schema protocol
609    /// instructions.
610    fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
611        // Arrange
612        let prompt = "Generate title";
613
614        // Act
615        let rendered_prompt = protocol_prepend_instructions(
616            prompt,
617            ProtocolRequestProfile::UtilityPrompt,
618            ProtocolSchemaInstructionMode::PromptSchema,
619            test_workspace_root(),
620        );
621
622        // Assert
623        assert!(rendered_prompt.contains("Structured response protocol:"));
624        assert!(
625            rendered_prompt
626                .contains("______________________________________________________________________")
627        );
628        assert!(rendered_prompt.contains("For this one-shot utility prompt"));
629        assert!(!rendered_prompt.contains("For this session turn:"));
630        assert!(rendered_prompt.contains(
631            r#"{"answer":"...","questions":[],"review_comment_outcomes":[],"summary":null}"#
632        ));
633        assert!(rendered_prompt.contains("\"review_comment_outcomes\""));
634        assert!(rendered_prompt.contains("\"summary\""));
635        assert!(rendered_prompt.ends_with(prompt));
636    }
637
638    #[test]
639    /// Ensures shared prompt preparation applies replay wrapping before
640    /// protocol instructions.
641    fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
642        // Arrange
643        let request = PromptPreparationRequest {
644            instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
645            personality_prompt: None,
646            personality_update: &PersonalityPromptUpdate::Unchanged,
647            prompt: "Continue edits",
648            protocol_profile: ProtocolRequestProfile::SessionTurn,
649            replay_transcript: Some("previous transcript"),
650            schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
651            workspace_root: test_workspace_root(),
652        };
653
654        // Act
655        let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
656
657        // Assert
658        assert!(prepared_prompt.contains("Structured response protocol:"));
659        assert!(prepared_prompt.contains("Workspace isolation requirements:"));
660        assert!(prepared_prompt.contains("previous transcript"));
661        assert!(prepared_prompt.contains(r"\<user_prompt> Continue edits \</user_prompt>"));
662        assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
663    }
664
665    #[test]
666    fn test_prepare_prompt_text_bootstraps_personality_before_user_prompt() {
667        // Arrange
668        let request = PromptPreparationRequest {
669            instruction_delivery_mode: InstructionDeliveryMode::BootstrapFull,
670            personality_prompt: Some("Review every change for correctness."),
671            personality_update: &PersonalityPromptUpdate::Unchanged,
672            prompt: "Inspect the patch.",
673            protocol_profile: ProtocolRequestProfile::SessionTurn,
674            replay_transcript: None,
675            schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
676            workspace_root: test_workspace_root(),
677        };
678
679        // Act
680        let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
681        let protocol_position = prepared_prompt
682            .find("Structured response protocol:")
683            .expect("protocol preamble should be present");
684        let personality_position = prepared_prompt
685            .find("# Personality\n\nReview every change for correctness.")
686            .expect("personality should be present");
687        let user_prompt_position = prepared_prompt
688            .find("Inspect the patch.")
689            .expect("user prompt should be present");
690
691        // Assert
692        assert!(protocol_position < personality_position);
693        assert!(personality_position < user_prompt_position);
694    }
695
696    #[test]
697    fn test_prepare_prompt_text_replays_with_current_personality() {
698        // Arrange
699        let request = PromptPreparationRequest {
700            instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
701            personality_prompt: Some("Plan before editing."),
702            personality_update: &PersonalityPromptUpdate::Unchanged,
703            prompt: "Continue.",
704            protocol_profile: ProtocolRequestProfile::SessionTurn,
705            replay_transcript: Some("assistant: prior work"),
706            schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
707            workspace_root: test_workspace_root(),
708        };
709
710        // Act
711        let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
712        let personality_position = prepared_prompt
713            .find("# Personality\n\nPlan before editing.")
714            .expect("personality should be present");
715        let transcript_position = prepared_prompt
716            .find(r"\<session_transcript> assistant: prior work")
717            .expect("transcript should be present");
718
719        // Assert
720        assert!(personality_position < transcript_position);
721        assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
722    }
723
724    #[test]
725    /// Ensures compact refresh reminders omit the full schema while keeping
726    /// the contract reminder and task body.
727    fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
728        // Arrange
729        let prompt = "Continue the implementation";
730
731        // Act
732        let rendered_prompt = protocol_prepend_refresh_reminder(
733            prompt,
734            ProtocolRequestProfile::SessionTurn,
735            test_workspace_root(),
736        );
737
738        let normalized_prompt = normalize_prompt(&rendered_prompt);
739
740        // Assert
741        assert!(rendered_prompt.contains("Protocol refresh reminder:"));
742        assert!(rendered_prompt.contains("repository-root-relative POSIX"));
743        assert!(normalized_prompt.contains("only read-only git commands; never mutating ones"));
744        assert!(rendered_prompt.contains("inside `/tmp/agentty-wt/session-1`"));
745        assert!(normalized_prompt.contains("everything outside this workspace root is read-only"));
746        assert!(
747            rendered_prompt
748                .contains("______________________________________________________________________")
749        );
750        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
751        assert!(rendered_prompt.ends_with(prompt));
752    }
753
754    #[test]
755    /// Ensures prompt preparation can emit the compact app-server reminder
756    /// instead of the full bootstrap wrapper.
757    fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
758        // Arrange
759        let request = PromptPreparationRequest {
760            instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
761            personality_prompt: None,
762            personality_update: &PersonalityPromptUpdate::Unchanged,
763            prompt: "Continue edits",
764            protocol_profile: ProtocolRequestProfile::SessionTurn,
765            replay_transcript: Some("previous transcript"),
766            schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
767            workspace_root: test_workspace_root(),
768        };
769
770        // Act
771        let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
772
773        // Assert
774        assert!(prepared_prompt.contains("Protocol refresh reminder:"));
775        assert!(!prepared_prompt.contains("Authoritative JSON Schema:"));
776        assert!(!prepared_prompt.contains("previous transcript"));
777        assert!(prepared_prompt.ends_with("Continue edits"));
778    }
779
780    #[test]
781    fn test_prepare_prompt_text_delta_mode_sends_personality_update_and_clear() {
782        // Arrange
783        let updated = PromptPreparationRequest {
784            instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
785            personality_prompt: Some("Ignored current body."),
786            personality_update: &PersonalityPromptUpdate::Set("Be concise.".to_string()),
787            prompt: "Continue edits",
788            protocol_profile: ProtocolRequestProfile::SessionTurn,
789            replay_transcript: None,
790            schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
791            workspace_root: test_workspace_root(),
792        };
793        let cleared = PromptPreparationRequest {
794            personality_update: &PersonalityPromptUpdate::Clear,
795            ..updated
796        };
797
798        // Act
799        let updated_prompt = prepare_prompt_text(updated).expect("update should render");
800        let cleared_prompt = prepare_prompt_text(cleared).expect("clear should render");
801
802        // Assert
803        assert!(updated_prompt.contains("# Personality Update\n\nBe concise."));
804        assert!(updated_prompt.ends_with("Continue edits"));
805        assert!(cleared_prompt.contains("The session personality has been cleared."));
806        assert!(cleared_prompt.ends_with("Continue edits"));
807    }
808
809    #[test]
810    /// Ensures CLI prompt rendering replaces image placeholders with local
811    /// file paths in placeholder order.
812    fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
813        // Arrange
814        let attachments = vec![
815            TurnPromptAttachment {
816                placeholder: "[Image #1]".to_string(),
817                local_image_path: PathBuf::from("/tmp/first-image.png"),
818            },
819            TurnPromptAttachment {
820                placeholder: "[Image #2]".to_string(),
821                local_image_path: PathBuf::from("/tmp/second-image.png"),
822            },
823        ];
824
825        // Act
826        let rendered_prompt = render_prompt_with_local_images(
827            "Compare [Image #2] with [Image #1]",
828            &attachments,
829            "TestBackend",
830        )
831        .expect("prompt rendering should succeed");
832
833        // Assert
834        assert_eq!(
835            rendered_prompt,
836            "Compare /tmp/second-image.png with /tmp/first-image.png"
837        );
838    }
839
840    #[test]
841    /// Ensures CLI prompt rendering appends local image paths when attachment
842    /// metadata survives without a placeholder match.
843    fn test_render_prompt_with_local_images_appends_missing_paths() {
844        // Arrange
845        let attachments = vec![TurnPromptAttachment {
846            placeholder: "[Image #1]".to_string(),
847            local_image_path: PathBuf::from("/tmp/first-image.png"),
848        }];
849
850        // Act
851        let rendered_prompt =
852            render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
853                .expect("prompt rendering should succeed");
854
855        // Assert
856        assert_eq!(
857            rendered_prompt,
858            "Review this change\n/tmp/first-image.png\n"
859        );
860    }
861
862    #[cfg(unix)]
863    #[test]
864    /// Ensures CLI prompt rendering fails fast with the provider label when an
865    /// attachment path is not valid UTF-8.
866    fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
867        // Arrange
868        let attachments = vec![TurnPromptAttachment {
869            placeholder: "[Image #1]".to_string(),
870            local_image_path: PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f])),
871        }];
872
873        // Act
874        let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
875            .expect_err("prompt rendering should fail");
876
877        // Assert
878        assert_eq!(
879            error,
880            AgentBackendError::CommandBuild(
881                "Claude prompt image path is not valid UTF-8".to_string()
882            )
883        );
884    }
885
886    #[test]
887    /// Ensures CLI prompt access roots deduplicate sorted attachment
888    /// directories when the provider only needs attachment parents.
889    fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
890        // Arrange
891        let workspace_folder = PathBuf::from("/tmp/session");
892        let attachments = vec![
893            TurnPromptAttachment {
894                placeholder: "[Image #1]".to_string(),
895                local_image_path: PathBuf::from("/tmp/images-b/two.png"),
896            },
897            TurnPromptAttachment {
898                placeholder: "[Image #2]".to_string(),
899                local_image_path: PathBuf::from("/tmp/images-a/one.png"),
900            },
901            TurnPromptAttachment {
902                placeholder: "[Image #3]".to_string(),
903                local_image_path: PathBuf::from("/tmp/images-a/three.png"),
904            },
905        ];
906
907        // Act
908        let directories = cli_prompt_access_directories(
909            &workspace_folder,
910            &attachments,
911            CliPromptAccessRootMode::AttachmentsOnly,
912        );
913
914        // Assert
915        assert_eq!(
916            directories,
917            vec![
918                PathBuf::from("/tmp/images-a"),
919                PathBuf::from("/tmp/images-b")
920            ]
921        );
922    }
923
924    #[test]
925    /// Ensures Antigravity-style access roots keep the workspace first and do
926    /// not duplicate it when an attachment also lives under that directory.
927    fn test_cli_prompt_access_directories_keeps_workspace_first() {
928        // Arrange
929        let workspace_folder = PathBuf::from("/tmp/z-session");
930        let attachments = vec![
931            TurnPromptAttachment {
932                placeholder: "[Image #1]".to_string(),
933                local_image_path: PathBuf::from("/tmp/z-session/one.png"),
934            },
935            TurnPromptAttachment {
936                placeholder: "[Image #2]".to_string(),
937                local_image_path: PathBuf::from("/tmp/a-images/two.png"),
938            },
939        ];
940
941        // Act
942        let directories = cli_prompt_access_directories(
943            &workspace_folder,
944            &attachments,
945            CliPromptAccessRootMode::WorkspaceThenAttachments,
946        );
947
948        // Assert
949        assert_eq!(
950            directories,
951            vec![workspace_folder, PathBuf::from("/tmp/a-images")]
952        );
953    }
954}