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 fn normalize_prompt(prompt: &str) -> String {
396 prompt.split_whitespace().collect::<Vec<_>>().join(" ")
397 }
398
399 #[test]
400 fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
403 let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
405
406 let fence = diff_fence(diff);
408
409 assert_eq!(fence, "```");
411 }
412
413 #[test]
414 fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
418 let diff = "+```\nsample\n+```\n";
420
421 let fence = diff_fence(diff);
423
424 assert_eq!(fence, "````");
426 }
427
428 #[test]
429 fn test_diff_fence_handles_long_backtick_runs() {
432 let diff = "prefix `````diff\ncontent\n`````\n";
434
435 let fence = diff_fence(diff);
437
438 assert_eq!(fence, "``````");
440 }
441
442 #[test]
443 fn test_build_resume_prompt_includes_replay_transcript_and_prompt() {
446 let prompt = "Continue tests; keep {{ transcript }} literal";
448 let replay_transcript = Some(" previous {{ prompt }} line \n");
449
450 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!(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 fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
477 let prompt = "Follow-up request";
479 let replay_transcript = Some(" ");
480
481 let resume_prompt =
483 build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
484
485 assert_eq!(resume_prompt, prompt);
487 }
488
489 #[test]
490 fn test_build_resume_prompt_returns_original_prompt_without_output() {
492 let prompt = "Retry merge";
494
495 let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
497
498 assert_eq!(resume_prompt, prompt);
500 }
501
502 #[test]
503 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
505 let prompt = "Implement feature";
507
508 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!(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 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
564 let prompt = "Implement feature";
566
567 let rendered_prompt = protocol_prepend_instructions(
569 prompt,
570 ProtocolRequestProfile::SessionTurn,
571 ProtocolSchemaInstructionMode::TransportSchema,
572 test_workspace_root(),
573 );
574
575 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 fn test_prepend_protocol_instructions_is_idempotent() {
587 let prompt = protocol_prepend_instructions(
589 "Implement feature",
590 ProtocolRequestProfile::SessionTurn,
591 ProtocolSchemaInstructionMode::PromptSchema,
592 test_workspace_root(),
593 );
594
595 let rendered_prompt = protocol_prepend_instructions(
597 &prompt,
598 ProtocolRequestProfile::UtilityPrompt,
599 ProtocolSchemaInstructionMode::TransportSchema,
600 test_workspace_root(),
601 );
602
603 assert_eq!(rendered_prompt, prompt);
605 }
606
607 #[test]
608 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
611 let prompt = "Generate title";
613
614 let rendered_prompt = protocol_prepend_instructions(
616 prompt,
617 ProtocolRequestProfile::UtilityPrompt,
618 ProtocolSchemaInstructionMode::PromptSchema,
619 test_workspace_root(),
620 );
621
622 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 fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
642 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 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
656
657 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 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 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!(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 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 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!(personality_position < transcript_position);
721 assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
722 }
723
724 #[test]
725 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
728 let prompt = "Continue the implementation";
730
731 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!(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 fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
758 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 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
772
773 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 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 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!(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 fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
813 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 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_eq!(
835 rendered_prompt,
836 "Compare /tmp/second-image.png with /tmp/first-image.png"
837 );
838 }
839
840 #[test]
841 fn test_render_prompt_with_local_images_appends_missing_paths() {
844 let attachments = vec![TurnPromptAttachment {
846 placeholder: "[Image #1]".to_string(),
847 local_image_path: PathBuf::from("/tmp/first-image.png"),
848 }];
849
850 let rendered_prompt =
852 render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
853 .expect("prompt rendering should succeed");
854
855 assert_eq!(
857 rendered_prompt,
858 "Review this change\n/tmp/first-image.png\n"
859 );
860 }
861
862 #[cfg(unix)]
863 #[test]
864 fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
867 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 let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
875 .expect_err("prompt rendering should fail");
876
877 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 fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
890 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 let directories = cli_prompt_access_directories(
909 &workspace_folder,
910 &attachments,
911 CliPromptAccessRootMode::AttachmentsOnly,
912 );
913
914 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 fn test_cli_prompt_access_directories_keeps_workspace_first() {
928 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 let directories = cli_prompt_access_directories(
943 &workspace_folder,
944 &attachments,
945 CliPromptAccessRootMode::WorkspaceThenAttachments,
946 );
947
948 assert_eq!(
950 directories,
951 vec![workspace_folder, PathBuf::from("/tmp/a-images")]
952 );
953 }
954}