1use 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#[derive(Template)]
20#[template(path = "resume_with_transcript_prompt.md", escape = "none")]
21struct ResumeWithTranscriptPromptTemplate<'a> {
22 prompt: &'a str,
24 transcript: &'a str,
26}
27
28#[derive(Template)]
30#[template(path = "personality_prompt.md", escape = "none")]
31struct PersonalityPromptTemplate<'a> {
32 heading: &'a str,
34 personality: &'a str,
36 prompt: &'a str,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub(crate) struct PromptPreparationRequest<'a> {
43 pub instruction_delivery_mode: InstructionDeliveryMode,
45 pub personality_prompt: Option<&'a str>,
47 pub personality_update: &'a PersonalityPromptUpdate,
49 pub prompt: &'a str,
51 pub protocol_profile: ProtocolRequestProfile,
53 pub replay_transcript: Option<&'a str>,
55 pub schema_instruction_mode: ProtocolSchemaInstructionMode,
58 pub workspace_root: &'a Path,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub(crate) enum CliPromptAccessRootMode {
67 AttachmentsOnly,
69 WorkspaceThenAttachments,
71}
72
73pub(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
114pub(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
134pub(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
168fn 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
188fn 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
210pub(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
228pub(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
276pub(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
308fn 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
327pub 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
354fn 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 fn test_workspace_root() -> &'static Path {
379 Path::new("/tmp/agentty-wt/session-1")
380 }
381
382 #[test]
383 fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
386 let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
388
389 let fence = diff_fence(diff);
391
392 assert_eq!(fence, "```");
394 }
395
396 #[test]
397 fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
401 let diff = "+```\nsample\n+```\n";
403
404 let fence = diff_fence(diff);
406
407 assert_eq!(fence, "````");
409 }
410
411 #[test]
412 fn test_diff_fence_handles_long_backtick_runs() {
415 let diff = "prefix `````diff\ncontent\n`````\n";
417
418 let fence = diff_fence(diff);
420
421 assert_eq!(fence, "``````");
423 }
424
425 #[test]
426 fn test_build_resume_prompt_includes_replay_transcript_and_prompt() {
429 let prompt = "Continue and update tests";
431 let replay_transcript = Some(" previous transcript line \n");
432
433 let resume_prompt =
435 build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
436
437 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 fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
451 let prompt = "Follow-up request";
453 let replay_transcript = Some(" ");
454
455 let resume_prompt =
457 build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
458
459 assert_eq!(resume_prompt, prompt);
461 }
462
463 #[test]
464 fn test_build_resume_prompt_returns_original_prompt_without_output() {
466 let prompt = "Retry merge";
468
469 let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
471
472 assert_eq!(resume_prompt, prompt);
474 }
475
476 #[test]
477 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
479 let prompt = "Implement feature";
481
482 let rendered_prompt = protocol_prepend_instructions(
484 prompt,
485 ProtocolRequestProfile::SessionTurn,
486 ProtocolSchemaInstructionMode::PromptSchema,
487 test_workspace_root(),
488 );
489
490 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 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
538 let prompt = "Implement feature";
540
541 let rendered_prompt = protocol_prepend_instructions(
543 prompt,
544 ProtocolRequestProfile::SessionTurn,
545 ProtocolSchemaInstructionMode::TransportSchema,
546 test_workspace_root(),
547 );
548
549 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 fn test_prepend_protocol_instructions_is_idempotent() {
561 let prompt = protocol_prepend_instructions(
563 "Implement feature",
564 ProtocolRequestProfile::SessionTurn,
565 ProtocolSchemaInstructionMode::PromptSchema,
566 test_workspace_root(),
567 );
568
569 let rendered_prompt = protocol_prepend_instructions(
571 &prompt,
572 ProtocolRequestProfile::UtilityPrompt,
573 ProtocolSchemaInstructionMode::TransportSchema,
574 test_workspace_root(),
575 );
576
577 assert_eq!(rendered_prompt, prompt);
579 }
580
581 #[test]
582 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
585 let prompt = "Generate title";
587
588 let rendered_prompt = protocol_prepend_instructions(
590 prompt,
591 ProtocolRequestProfile::UtilityPrompt,
592 ProtocolSchemaInstructionMode::PromptSchema,
593 test_workspace_root(),
594 );
595
596 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 fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
615 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 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
629
630 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 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 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!(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 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 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
685
686 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 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
696 let prompt = "Continue the implementation";
698
699 let rendered_prompt = protocol_prepend_refresh_reminder(
701 prompt,
702 ProtocolRequestProfile::SessionTurn,
703 test_workspace_root(),
704 );
705
706 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 fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
725 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 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
739
740 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 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 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!(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 fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
780 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 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_eq!(
802 rendered_prompt,
803 "Compare /tmp/second-image.png with /tmp/first-image.png"
804 );
805 }
806
807 #[test]
808 fn test_render_prompt_with_local_images_appends_missing_paths() {
811 let attachments = vec![TurnPromptAttachment {
813 placeholder: "[Image #1]".to_string(),
814 local_image_path: PathBuf::from("/tmp/first-image.png"),
815 }];
816
817 let rendered_prompt =
819 render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
820 .expect("prompt rendering should succeed");
821
822 assert_eq!(
824 rendered_prompt,
825 "Review this change\n/tmp/first-image.png\n"
826 );
827 }
828
829 #[cfg(unix)]
830 #[test]
831 fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
834 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 let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
842 .expect_err("prompt rendering should fail");
843
844 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 fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
857 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 let directories = cli_prompt_access_directories(
876 &workspace_folder,
877 &attachments,
878 CliPromptAccessRootMode::AttachmentsOnly,
879 );
880
881 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 fn test_cli_prompt_access_directories_keeps_workspace_first() {
895 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 let directories = cli_prompt_access_directories(
910 &workspace_folder,
911 &attachments,
912 CliPromptAccessRootMode::WorkspaceThenAttachments,
913 );
914
915 assert_eq!(
917 directories,
918 vec![workspace_folder, PathBuf::from("/tmp/a-images")]
919 );
920 }
921}