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