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_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
167pub(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
180fn 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
200fn 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
222pub(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
240pub(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
288pub(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
320fn 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
339pub 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
366fn 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 fn test_workspace_root() -> &'static Path {
391 Path::new("/tmp/agentty-wt/session-1")
392 }
393
394 #[test]
395 fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
398 let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
400
401 let fence = diff_fence(diff);
403
404 assert_eq!(fence, "```");
406 }
407
408 #[test]
409 fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
413 let diff = "+```\nsample\n+```\n";
415
416 let fence = diff_fence(diff);
418
419 assert_eq!(fence, "````");
421 }
422
423 #[test]
424 fn test_diff_fence_handles_long_backtick_runs() {
427 let diff = "prefix `````diff\ncontent\n`````\n";
429
430 let fence = diff_fence(diff);
432
433 assert_eq!(fence, "``````");
435 }
436
437 #[test]
438 fn test_build_resume_prompt_includes_replay_transcript_and_prompt() {
441 let prompt = "Continue and update tests";
443 let replay_transcript = Some(" previous transcript line \n");
444
445 let resume_prompt =
447 build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
448
449 let normalized_resume_prompt = resume_prompt.split_whitespace().collect::<Vec<_>>();
451 let normalized_resume_prompt = normalized_resume_prompt.join(" ");
452 assert!(resume_prompt.contains("previous transcript line"));
453 assert!(normalized_resume_prompt.contains("Treat the user's new prompt as a follow-up"));
454 assert!(normalized_resume_prompt.contains("changes made during this session"));
455 assert!(normalized_resume_prompt.contains("preserve unrelated pre-existing work"));
456 assert!(resume_prompt.contains("Continue and update tests"));
457 }
458
459 #[test]
460 fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
463 let prompt = "Follow-up request";
465 let replay_transcript = Some(" ");
466
467 let resume_prompt =
469 build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
470
471 assert_eq!(resume_prompt, prompt);
473 }
474
475 #[test]
476 fn test_build_resume_prompt_returns_original_prompt_without_output() {
478 let prompt = "Retry merge";
480
481 let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
483
484 assert_eq!(resume_prompt, prompt);
486 }
487
488 #[test]
489 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
491 let prompt = "Implement feature";
493
494 let rendered_prompt = protocol_prepend_instructions(
496 prompt,
497 ProtocolRequestProfile::SessionTurn,
498 ProtocolSchemaInstructionMode::PromptSchema,
499 test_workspace_root(),
500 );
501
502 assert!(rendered_prompt.contains("File path output requirements:"));
504 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
505 assert!(rendered_prompt.contains("Your workspace root is `/tmp/agentty-wt/session-1`."));
506 assert!(rendered_prompt.contains("Anything outside that"));
507 assert!(rendered_prompt.contains("root is read-only."));
508 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
509 assert!(
510 rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
511 );
512 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
513 assert!(rendered_prompt.contains("Do not run mutating git commands"));
514 assert!(rendered_prompt.contains("Quality check requirements:"));
515 assert!(rendered_prompt.contains("repository-defined quality checks"));
516 let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
517 let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
518 assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
519 assert!(rendered_prompt.contains("full repository test/check suite"));
520 assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
521 assert!(rendered_prompt.contains("Structured response protocol:"));
522 assert!(rendered_prompt.contains("Return a single JSON object"));
523 assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
524 assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
525 assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
526 assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
527 assert!(
528 rendered_prompt
529 .contains("______________________________________________________________________")
530 );
531 assert!(!rendered_prompt.contains("{# task separator #}"));
532 assert!(rendered_prompt.contains("For this session turn"));
533 assert!(normalized_rendered_prompt.contains("Do not create commits"));
534 assert!(normalized_rendered_prompt.contains("suggest creating commits"));
535 assert!(rendered_prompt.contains("summary"));
536 assert!(rendered_prompt.contains("turn"));
537 assert!(rendered_prompt.contains("session"));
538 assert!(rendered_prompt.contains("\"answer\""));
539 assert!(rendered_prompt.contains("\"questions\""));
540 assert!(rendered_prompt.contains("\"title\""));
541 assert!(rendered_prompt.contains("\"description\""));
542 assert!(rendered_prompt.contains("summary"));
543 assert!(rendered_prompt.ends_with(prompt));
544 }
545
546 #[test]
547 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
550 let prompt = "Implement feature";
552
553 let rendered_prompt = protocol_prepend_instructions(
555 prompt,
556 ProtocolRequestProfile::SessionTurn,
557 ProtocolSchemaInstructionMode::TransportSchema,
558 test_workspace_root(),
559 );
560
561 assert!(rendered_prompt.contains("Structured response protocol:"));
563 assert!(rendered_prompt.contains("provider enforces the response JSON schema"));
564 assert!(rendered_prompt.contains("Return a single JSON object"));
565 assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
566 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
567 assert!(rendered_prompt.ends_with(prompt));
568 }
569
570 #[test]
571 fn test_prepend_protocol_instructions_is_idempotent() {
573 let prompt = protocol_prepend_instructions(
575 "Implement feature",
576 ProtocolRequestProfile::SessionTurn,
577 ProtocolSchemaInstructionMode::PromptSchema,
578 test_workspace_root(),
579 );
580
581 let rendered_prompt = protocol_prepend_instructions(
583 &prompt,
584 ProtocolRequestProfile::UtilityPrompt,
585 ProtocolSchemaInstructionMode::TransportSchema,
586 test_workspace_root(),
587 );
588
589 assert_eq!(rendered_prompt, prompt);
591 }
592
593 #[test]
594 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
597 let prompt = "Generate title";
599
600 let rendered_prompt = protocol_prepend_instructions(
602 prompt,
603 ProtocolRequestProfile::UtilityPrompt,
604 ProtocolSchemaInstructionMode::PromptSchema,
605 test_workspace_root(),
606 );
607
608 assert!(rendered_prompt.contains("Structured response protocol:"));
610 assert!(
611 rendered_prompt
612 .contains("______________________________________________________________________")
613 );
614 assert!(rendered_prompt.contains("For this one-shot utility prompt"));
615 assert!(rendered_prompt.contains(
616 r#"{"answer":"...","questions":[],"review_comment_outcomes":[],"summary":null}"#
617 ));
618 assert!(rendered_prompt.contains("\"review_comment_outcomes\""));
619 assert!(rendered_prompt.contains("\"summary\""));
620 assert!(rendered_prompt.ends_with(prompt));
621 }
622
623 #[test]
624 fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
627 let request = PromptPreparationRequest {
629 instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
630 personality_prompt: None,
631 personality_update: &PersonalityPromptUpdate::Unchanged,
632 prompt: "Continue edits",
633 protocol_profile: ProtocolRequestProfile::SessionTurn,
634 replay_transcript: Some("previous transcript"),
635 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
636 workspace_root: test_workspace_root(),
637 };
638
639 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
641
642 assert!(prepared_prompt.contains("Structured response protocol:"));
644 assert!(prepared_prompt.contains("Workspace isolation requirements:"));
645 assert!(prepared_prompt.contains("previous transcript"));
646 assert!(prepared_prompt.contains(r"\<user_prompt> Continue edits \</user_prompt>"));
647 assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
648 }
649
650 #[test]
651 fn test_prepare_prompt_text_bootstraps_personality_before_user_prompt() {
652 let request = PromptPreparationRequest {
654 instruction_delivery_mode: InstructionDeliveryMode::BootstrapFull,
655 personality_prompt: Some("Review every change for correctness."),
656 personality_update: &PersonalityPromptUpdate::Unchanged,
657 prompt: "Inspect the patch.",
658 protocol_profile: ProtocolRequestProfile::SessionTurn,
659 replay_transcript: None,
660 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
661 workspace_root: test_workspace_root(),
662 };
663
664 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
666 let protocol_position = prepared_prompt
667 .find("Structured response protocol:")
668 .expect("protocol preamble should be present");
669 let personality_position = prepared_prompt
670 .find("# Personality\n\nReview every change for correctness.")
671 .expect("personality should be present");
672 let user_prompt_position = prepared_prompt
673 .find("Inspect the patch.")
674 .expect("user prompt should be present");
675
676 assert!(protocol_position < personality_position);
678 assert!(personality_position < user_prompt_position);
679 }
680
681 #[test]
682 fn test_prepare_prompt_text_replays_with_current_personality() {
683 let request = PromptPreparationRequest {
685 instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
686 personality_prompt: Some("Plan before editing."),
687 personality_update: &PersonalityPromptUpdate::Unchanged,
688 prompt: "Continue.",
689 protocol_profile: ProtocolRequestProfile::SessionTurn,
690 replay_transcript: Some("assistant: prior work"),
691 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
692 workspace_root: test_workspace_root(),
693 };
694
695 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
697
698 assert!(prepared_prompt.contains("# Personality\n\nPlan before editing."));
700 assert!(prepared_prompt.contains("assistant: prior work"));
701 assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
702 }
703
704 #[test]
705 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
708 let prompt = "Continue the implementation";
710
711 let rendered_prompt = protocol_prepend_refresh_reminder(
713 prompt,
714 ProtocolRequestProfile::SessionTurn,
715 test_workspace_root(),
716 );
717
718 assert!(rendered_prompt.contains("Protocol refresh reminder:"));
720 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
721 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
722 assert!(rendered_prompt.contains("Do not run mutating git commands."));
723 assert!(rendered_prompt.contains("inside the workspace root `/tmp/agentty-wt/session-1`"));
724 assert!(rendered_prompt.contains("anything outside that root is read-only"));
725 assert!(
726 rendered_prompt
727 .contains("______________________________________________________________________")
728 );
729 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
730 assert!(rendered_prompt.ends_with(prompt));
731 }
732
733 #[test]
734 fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
737 let request = PromptPreparationRequest {
739 instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
740 personality_prompt: None,
741 personality_update: &PersonalityPromptUpdate::Unchanged,
742 prompt: "Continue edits",
743 protocol_profile: ProtocolRequestProfile::SessionTurn,
744 replay_transcript: Some("previous transcript"),
745 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
746 workspace_root: test_workspace_root(),
747 };
748
749 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
751
752 assert!(prepared_prompt.contains("Protocol refresh reminder:"));
754 assert!(!prepared_prompt.contains("Authoritative JSON Schema:"));
755 assert!(!prepared_prompt.contains("previous transcript"));
756 assert!(prepared_prompt.ends_with("Continue edits"));
757 }
758
759 #[test]
760 fn test_prepare_prompt_text_delta_mode_sends_personality_update_and_clear() {
761 let updated = PromptPreparationRequest {
763 instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
764 personality_prompt: Some("Ignored current body."),
765 personality_update: &PersonalityPromptUpdate::Set("Be concise.".to_string()),
766 prompt: "Continue edits",
767 protocol_profile: ProtocolRequestProfile::SessionTurn,
768 replay_transcript: None,
769 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
770 workspace_root: test_workspace_root(),
771 };
772 let cleared = PromptPreparationRequest {
773 personality_update: &PersonalityPromptUpdate::Clear,
774 ..updated
775 };
776
777 let updated_prompt = prepare_prompt_text(updated).expect("update should render");
779 let cleared_prompt = prepare_prompt_text(cleared).expect("clear should render");
780
781 assert!(updated_prompt.contains("# Personality Update\n\nBe concise."));
783 assert!(updated_prompt.ends_with("Continue edits"));
784 assert!(cleared_prompt.contains("The session personality has been cleared."));
785 assert!(cleared_prompt.ends_with("Continue edits"));
786 }
787
788 #[test]
789 fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
792 let attachments = vec![
794 TurnPromptAttachment {
795 placeholder: "[Image #1]".to_string(),
796 local_image_path: PathBuf::from("/tmp/first-image.png"),
797 },
798 TurnPromptAttachment {
799 placeholder: "[Image #2]".to_string(),
800 local_image_path: PathBuf::from("/tmp/second-image.png"),
801 },
802 ];
803
804 let rendered_prompt = render_prompt_with_local_images(
806 "Compare [Image #2] with [Image #1]",
807 &attachments,
808 "TestBackend",
809 )
810 .expect("prompt rendering should succeed");
811
812 assert_eq!(
814 rendered_prompt,
815 "Compare /tmp/second-image.png with /tmp/first-image.png"
816 );
817 }
818
819 #[test]
820 fn test_render_prompt_with_local_images_appends_missing_paths() {
823 let attachments = vec![TurnPromptAttachment {
825 placeholder: "[Image #1]".to_string(),
826 local_image_path: PathBuf::from("/tmp/first-image.png"),
827 }];
828
829 let rendered_prompt =
831 render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
832 .expect("prompt rendering should succeed");
833
834 assert_eq!(
836 rendered_prompt,
837 "Review this change\n/tmp/first-image.png\n"
838 );
839 }
840
841 #[cfg(unix)]
842 #[test]
843 fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
846 let attachments = vec![TurnPromptAttachment {
848 placeholder: "[Image #1]".to_string(),
849 local_image_path: PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f])),
850 }];
851
852 let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
854 .expect_err("prompt rendering should fail");
855
856 assert_eq!(
858 error,
859 AgentBackendError::CommandBuild(
860 "Claude prompt image path is not valid UTF-8".to_string()
861 )
862 );
863 }
864
865 #[test]
866 fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
869 let workspace_folder = PathBuf::from("/tmp/session");
871 let attachments = vec![
872 TurnPromptAttachment {
873 placeholder: "[Image #1]".to_string(),
874 local_image_path: PathBuf::from("/tmp/images-b/two.png"),
875 },
876 TurnPromptAttachment {
877 placeholder: "[Image #2]".to_string(),
878 local_image_path: PathBuf::from("/tmp/images-a/one.png"),
879 },
880 TurnPromptAttachment {
881 placeholder: "[Image #3]".to_string(),
882 local_image_path: PathBuf::from("/tmp/images-a/three.png"),
883 },
884 ];
885
886 let directories = cli_prompt_access_directories(
888 &workspace_folder,
889 &attachments,
890 CliPromptAccessRootMode::AttachmentsOnly,
891 );
892
893 assert_eq!(
895 directories,
896 vec![
897 PathBuf::from("/tmp/images-a"),
898 PathBuf::from("/tmp/images-b")
899 ]
900 );
901 }
902
903 #[test]
904 fn test_cli_prompt_access_directories_keeps_workspace_first() {
907 let workspace_folder = PathBuf::from("/tmp/z-session");
909 let attachments = vec![
910 TurnPromptAttachment {
911 placeholder: "[Image #1]".to_string(),
912 local_image_path: PathBuf::from("/tmp/z-session/one.png"),
913 },
914 TurnPromptAttachment {
915 placeholder: "[Image #2]".to_string(),
916 local_image_path: PathBuf::from("/tmp/a-images/two.png"),
917 },
918 ];
919
920 let directories = cli_prompt_access_directories(
922 &workspace_folder,
923 &attachments,
924 CliPromptAccessRootMode::WorkspaceThenAttachments,
925 );
926
927 assert_eq!(
929 directories,
930 vec![workspace_folder, PathBuf::from("/tmp/a-images")]
931 );
932 }
933}