1use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub use ag_protocol::ProtocolSchemaInstructionMode;
7use ag_protocol::{
8 ProtocolRequestProfile, prepend_protocol_instructions as protocol_prepend_instructions,
9 prepend_protocol_refresh_reminder as protocol_prepend_refresh_reminder,
10};
11use askama::Template;
12
13use super::backend::{AgentBackendError, BuildCommandRequest};
14use super::instruction::InstructionDeliveryMode;
15use crate::model::turn_prompt::{
16 TurnPromptAttachment, TurnPromptContentPart, split_turn_prompt_content,
17};
18
19#[derive(Template)]
21#[template(path = "resume_with_session_output_prompt.md", escape = "none")]
22struct ResumeWithSessionOutputPromptTemplate<'a> {
23 prompt: &'a str,
25 session_output: &'a str,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct PromptPreparationRequest<'a> {
32 pub instruction_delivery_mode: InstructionDeliveryMode,
34 pub prompt: &'a str,
36 pub protocol_profile: ProtocolRequestProfile,
38 pub replay_session_output: Option<&'a str>,
40 pub schema_instruction_mode: ProtocolSchemaInstructionMode,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) enum CliPromptAccessRootMode {
49 AttachmentsOnly,
51 WorkspaceThenAttachments,
53}
54
55pub fn prepare_prompt_text(
60 request: PromptPreparationRequest<'_>,
61) -> Result<String, AgentBackendError> {
62 match request.instruction_delivery_mode {
63 InstructionDeliveryMode::BootstrapFull => Ok(protocol_prepend_instructions(
64 request.prompt,
65 request.protocol_profile,
66 request.schema_instruction_mode,
67 )),
68 InstructionDeliveryMode::DeltaOnly => Ok(protocol_prepend_refresh_reminder(
69 request.prompt,
70 request.protocol_profile,
71 )),
72 InstructionDeliveryMode::BootstrapWithReplay => {
73 let prompt = build_resume_prompt(request.prompt, request.replay_session_output)?;
74
75 Ok(protocol_prepend_instructions(
76 &prompt,
77 request.protocol_profile,
78 request.schema_instruction_mode,
79 ))
80 }
81 }
82}
83
84pub(crate) fn build_resume_prompt(
89 prompt: &str,
90 session_output: Option<&str>,
91) -> Result<String, AgentBackendError> {
92 let Some(session_output) = session_output
93 .map(str::trim)
94 .filter(|value| !value.is_empty())
95 else {
96 return Ok(prompt.to_string());
97 };
98
99 let template = ResumeWithSessionOutputPromptTemplate {
100 prompt,
101 session_output,
102 };
103
104 render_template("resume_with_session_output_prompt.md", &template)
105}
106
107pub(crate) fn build_prompt_stdin_payload(
117 request: BuildCommandRequest<'_>,
118 schema_instruction_mode: ProtocolSchemaInstructionMode,
119 backend_display_name: &str,
120) -> Result<Vec<u8>, AgentBackendError> {
121 let prompt =
122 render_prompt_with_local_images(request.prompt, request.attachments, backend_display_name)?;
123 let prompt = prepare_prompt_text(PromptPreparationRequest {
124 instruction_delivery_mode: if request.request_kind.is_resume() {
125 InstructionDeliveryMode::BootstrapWithReplay
126 } else {
127 InstructionDeliveryMode::BootstrapFull
128 },
129 prompt: &prompt,
130 protocol_profile: request.request_kind.protocol_profile(),
131 replay_session_output: request.request_kind.session_output(),
132 schema_instruction_mode,
133 })?;
134
135 Ok(prompt.into_bytes())
136}
137
138pub(crate) fn append_cli_prompt_access_directories(
146 command: &mut Command,
147 workspace_folder: &Path,
148 attachments: &[TurnPromptAttachment],
149 root_mode: CliPromptAccessRootMode,
150) {
151 for directory in cli_prompt_access_directories(workspace_folder, attachments, root_mode) {
152 command.arg("--add-dir").arg(directory);
153 }
154}
155
156pub(crate) fn render_prompt_with_local_images(
165 prompt: &str,
166 attachments: &[TurnPromptAttachment],
167 backend_display_name: &str,
168) -> Result<String, AgentBackendError> {
169 if attachments.is_empty() {
170 return Ok(prompt.to_string());
171 }
172
173 let mut rendered_prompt = String::new();
174
175 for content_part in split_turn_prompt_content(prompt, attachments) {
176 match content_part {
177 TurnPromptContentPart::Text(text) => rendered_prompt.push_str(text),
178 TurnPromptContentPart::Attachment(attachment) => {
179 let attachment_path = attachment_path_for_prompt(backend_display_name, attachment)?;
180 rendered_prompt.push_str(&attachment_path);
181 }
182 TurnPromptContentPart::OrphanAttachment(attachment) => {
183 if !rendered_prompt.is_empty()
184 && rendered_prompt
185 .chars()
186 .last()
187 .is_some_and(|character| !character.is_whitespace())
188 {
189 rendered_prompt.push('\n');
190 }
191
192 rendered_prompt.push_str(&attachment_path_for_prompt(
193 backend_display_name,
194 attachment,
195 )?);
196 rendered_prompt.push('\n');
197 }
198 }
199 }
200
201 Ok(rendered_prompt)
202}
203
204pub(crate) fn cli_prompt_access_directories(
210 workspace_folder: &Path,
211 attachments: &[TurnPromptAttachment],
212 root_mode: CliPromptAccessRootMode,
213) -> Vec<PathBuf> {
214 let mut attachment_directories = attachments
215 .iter()
216 .filter_map(|attachment| attachment.local_image_path.parent())
217 .map(ToOwned::to_owned)
218 .collect::<Vec<_>>();
219 attachment_directories.sort();
220 attachment_directories.dedup();
221
222 if matches!(root_mode, CliPromptAccessRootMode::AttachmentsOnly) {
223 return attachment_directories;
224 }
225
226 attachment_directories
227 .retain(|attachment_directory| attachment_directory.as_path() != workspace_folder);
228
229 let mut workspace_directories = Vec::with_capacity(attachment_directories.len() + 1);
230 workspace_directories.push(workspace_folder.to_path_buf());
231 workspace_directories.extend(attachment_directories);
232
233 workspace_directories
234}
235
236fn attachment_path_for_prompt(
241 backend_display_name: &str,
242 attachment: &TurnPromptAttachment,
243) -> Result<String, AgentBackendError> {
244 attachment
245 .local_image_path
246 .to_str()
247 .map(ToOwned::to_owned)
248 .ok_or_else(|| {
249 AgentBackendError::CommandBuild(format!(
250 "{backend_display_name} prompt image path is not valid UTF-8"
251 ))
252 })
253}
254
255pub fn diff_fence(content: &str) -> String {
264 let mut max_run = 0usize;
265 let mut current_run = 0usize;
266 for character in content.chars() {
267 if character == '`' {
268 current_run += 1;
269 if current_run > max_run {
270 max_run = current_run;
271 }
272 } else {
273 current_run = 0;
274 }
275 }
276
277 let fence_length = std::cmp::max(3, max_run + 1);
278
279 "`".repeat(fence_length)
280}
281
282fn render_template(
285 template_name: &str,
286 template: &impl Template,
287) -> Result<String, AgentBackendError> {
288 let rendered = template.render().map_err(|error| {
289 AgentBackendError::CommandBuild(format!("Failed to render `{template_name}`: {error}"))
290 })?;
291
292 Ok(rendered.trim_end().to_string())
293}
294
295#[cfg(test)]
296mod tests {
297 #[cfg(unix)]
298 use std::ffi::OsString;
299 #[cfg(unix)]
300 use std::os::unix::ffi::OsStringExt;
301 use std::path::PathBuf;
302
303 use super::*;
304
305 #[test]
306 fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
309 let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
311
312 let fence = diff_fence(diff);
314
315 assert_eq!(fence, "```");
317 }
318
319 #[test]
320 fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
324 let diff = "+```\nsample\n+```\n";
326
327 let fence = diff_fence(diff);
329
330 assert_eq!(fence, "````");
332 }
333
334 #[test]
335 fn test_diff_fence_handles_long_backtick_runs() {
338 let diff = "prefix `````diff\ncontent\n`````\n";
340
341 let fence = diff_fence(diff);
343
344 assert_eq!(fence, "``````");
346 }
347
348 #[test]
349 fn test_build_resume_prompt_includes_session_output_and_prompt() {
352 let prompt = "Continue and update tests";
354 let session_output = Some(" previous output line \n");
355
356 let resume_prompt =
358 build_resume_prompt(prompt, session_output).expect("resume prompt should render");
359
360 let normalized_resume_prompt = resume_prompt.split_whitespace().collect::<Vec<_>>();
362 let normalized_resume_prompt = normalized_resume_prompt.join(" ");
363 assert!(resume_prompt.contains("previous output line"));
364 assert!(normalized_resume_prompt.contains("Treat the user's new prompt as a follow-up"));
365 assert!(normalized_resume_prompt.contains("changes made during this Agentty session"));
366 assert!(normalized_resume_prompt.contains("preserve unrelated pre-existing work"));
367 assert!(resume_prompt.contains("Continue and update tests"));
368 }
369
370 #[test]
371 fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
374 let prompt = "Follow-up request";
376 let session_output = Some(" ");
377
378 let resume_prompt =
380 build_resume_prompt(prompt, session_output).expect("resume prompt should render");
381
382 assert_eq!(resume_prompt, prompt);
384 }
385
386 #[test]
387 fn test_build_resume_prompt_returns_original_prompt_without_output() {
389 let prompt = "Retry merge";
391
392 let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
394
395 assert_eq!(resume_prompt, prompt);
397 }
398
399 #[test]
400 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
402 let prompt = "Implement feature";
404
405 let rendered_prompt = protocol_prepend_instructions(
407 prompt,
408 ProtocolRequestProfile::SessionTurn,
409 ProtocolSchemaInstructionMode::PromptSchema,
410 );
411
412 assert!(rendered_prompt.contains("File path output requirements:"));
414 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
415 assert!(
416 rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
417 );
418 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
419 assert!(rendered_prompt.contains("Do not run mutating git commands"));
420 assert!(rendered_prompt.contains("Quality check requirements:"));
421 assert!(rendered_prompt.contains("repository-defined quality checks"));
422 let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
423 let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
424 assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
425 assert!(rendered_prompt.contains("full repository test/check suite"));
426 assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
427 assert!(rendered_prompt.contains("Structured response protocol:"));
428 assert!(rendered_prompt.contains("Return a single JSON object"));
429 assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
430 assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
431 assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
432 assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
433 assert!(
434 rendered_prompt
435 .contains("______________________________________________________________________")
436 );
437 assert!(!rendered_prompt.contains("{# task separator #}"));
438 assert!(rendered_prompt.contains("For this session turn"));
439 assert!(normalized_rendered_prompt.contains("Do not create commits"));
440 assert!(normalized_rendered_prompt.contains("suggest creating commits"));
441 assert!(rendered_prompt.contains("summary"));
442 assert!(rendered_prompt.contains("turn"));
443 assert!(rendered_prompt.contains("session"));
444 assert!(rendered_prompt.contains("\"answer\""));
445 assert!(rendered_prompt.contains("\"questions\""));
446 assert!(rendered_prompt.contains("\"title\""));
447 assert!(rendered_prompt.contains("\"description\""));
448 assert!(rendered_prompt.contains("summary"));
449 assert!(rendered_prompt.ends_with(prompt));
450 }
451
452 #[test]
453 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
456 let prompt = "Implement feature";
458
459 let rendered_prompt = protocol_prepend_instructions(
461 prompt,
462 ProtocolRequestProfile::SessionTurn,
463 ProtocolSchemaInstructionMode::TransportSchema,
464 );
465
466 assert!(rendered_prompt.contains("Structured response protocol:"));
468 assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
469 assert!(rendered_prompt.contains("Return a single JSON object"));
470 assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
471 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
472 assert!(rendered_prompt.ends_with(prompt));
473 }
474
475 #[test]
476 fn test_prepend_protocol_instructions_is_idempotent() {
478 let prompt = protocol_prepend_instructions(
480 "Implement feature",
481 ProtocolRequestProfile::SessionTurn,
482 ProtocolSchemaInstructionMode::PromptSchema,
483 );
484
485 let rendered_prompt = protocol_prepend_instructions(
487 &prompt,
488 ProtocolRequestProfile::UtilityPrompt,
489 ProtocolSchemaInstructionMode::TransportSchema,
490 );
491
492 assert_eq!(rendered_prompt, prompt);
494 }
495
496 #[test]
497 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
500 let prompt = "Generate title";
502
503 let rendered_prompt = protocol_prepend_instructions(
505 prompt,
506 ProtocolRequestProfile::UtilityPrompt,
507 ProtocolSchemaInstructionMode::PromptSchema,
508 );
509
510 assert!(rendered_prompt.contains("Structured response protocol:"));
512 assert!(
513 rendered_prompt
514 .contains("______________________________________________________________________")
515 );
516 assert!(rendered_prompt.contains("For this one-shot utility prompt"));
517 assert!(rendered_prompt.contains(r#"{"answer":"...","questions":[],"summary":null}"#));
518 assert!(rendered_prompt.contains("\"summary\""));
519 assert!(rendered_prompt.ends_with(prompt));
520 }
521
522 #[test]
523 fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
526 let request = PromptPreparationRequest {
528 instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
529 prompt: "Continue edits",
530 protocol_profile: ProtocolRequestProfile::SessionTurn,
531 replay_session_output: Some("previous output"),
532 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
533 };
534
535 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
537
538 assert!(prepared_prompt.contains("Structured response protocol:"));
540 assert!(prepared_prompt.contains("previous output"));
541 assert!(prepared_prompt.contains(r"\<user_prompt> Continue edits \</user_prompt>"));
542 assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
543 }
544
545 #[test]
546 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
549 let prompt = "Continue the implementation";
551
552 let rendered_prompt =
554 protocol_prepend_refresh_reminder(prompt, ProtocolRequestProfile::SessionTurn);
555
556 assert!(rendered_prompt.contains("Protocol refresh reminder:"));
558 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
559 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
560 assert!(rendered_prompt.contains("Do not run mutating git commands."));
561 assert!(
562 rendered_prompt
563 .contains("______________________________________________________________________")
564 );
565 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
566 assert!(rendered_prompt.ends_with(prompt));
567 }
568
569 #[test]
570 fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
573 let request = PromptPreparationRequest {
575 instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
576 prompt: "Continue edits",
577 protocol_profile: ProtocolRequestProfile::SessionTurn,
578 replay_session_output: Some("previous output"),
579 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
580 };
581
582 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
584
585 assert!(prepared_prompt.contains("Protocol refresh reminder:"));
587 assert!(!prepared_prompt.contains("Authoritative JSON Schema:"));
588 assert!(!prepared_prompt.contains("previous output"));
589 assert!(prepared_prompt.ends_with("Continue edits"));
590 }
591
592 #[test]
593 fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
596 let attachments = vec![
598 TurnPromptAttachment {
599 placeholder: "[Image #1]".to_string(),
600 local_image_path: PathBuf::from("/tmp/first-image.png"),
601 },
602 TurnPromptAttachment {
603 placeholder: "[Image #2]".to_string(),
604 local_image_path: PathBuf::from("/tmp/second-image.png"),
605 },
606 ];
607
608 let rendered_prompt = render_prompt_with_local_images(
610 "Compare [Image #2] with [Image #1]",
611 &attachments,
612 "TestBackend",
613 )
614 .expect("prompt rendering should succeed");
615
616 assert_eq!(
618 rendered_prompt,
619 "Compare /tmp/second-image.png with /tmp/first-image.png"
620 );
621 }
622
623 #[test]
624 fn test_render_prompt_with_local_images_appends_missing_paths() {
627 let attachments = vec![TurnPromptAttachment {
629 placeholder: "[Image #1]".to_string(),
630 local_image_path: PathBuf::from("/tmp/first-image.png"),
631 }];
632
633 let rendered_prompt =
635 render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
636 .expect("prompt rendering should succeed");
637
638 assert_eq!(
640 rendered_prompt,
641 "Review this change\n/tmp/first-image.png\n"
642 );
643 }
644
645 #[cfg(unix)]
646 #[test]
647 fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
650 let attachments = vec![TurnPromptAttachment {
652 placeholder: "[Image #1]".to_string(),
653 local_image_path: PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f])),
654 }];
655
656 let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
658 .expect_err("prompt rendering should fail");
659
660 assert_eq!(
662 error,
663 AgentBackendError::CommandBuild(
664 "Claude prompt image path is not valid UTF-8".to_string()
665 )
666 );
667 }
668
669 #[test]
670 fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
673 let workspace_folder = PathBuf::from("/tmp/session");
675 let attachments = vec![
676 TurnPromptAttachment {
677 placeholder: "[Image #1]".to_string(),
678 local_image_path: PathBuf::from("/tmp/images-b/two.png"),
679 },
680 TurnPromptAttachment {
681 placeholder: "[Image #2]".to_string(),
682 local_image_path: PathBuf::from("/tmp/images-a/one.png"),
683 },
684 TurnPromptAttachment {
685 placeholder: "[Image #3]".to_string(),
686 local_image_path: PathBuf::from("/tmp/images-a/three.png"),
687 },
688 ];
689
690 let directories = cli_prompt_access_directories(
692 &workspace_folder,
693 &attachments,
694 CliPromptAccessRootMode::AttachmentsOnly,
695 );
696
697 assert_eq!(
699 directories,
700 vec![
701 PathBuf::from("/tmp/images-a"),
702 PathBuf::from("/tmp/images-b")
703 ]
704 );
705 }
706
707 #[test]
708 fn test_cli_prompt_access_directories_keeps_workspace_first() {
711 let workspace_folder = PathBuf::from("/tmp/z-session");
713 let attachments = vec![
714 TurnPromptAttachment {
715 placeholder: "[Image #1]".to_string(),
716 local_image_path: PathBuf::from("/tmp/z-session/one.png"),
717 },
718 TurnPromptAttachment {
719 placeholder: "[Image #2]".to_string(),
720 local_image_path: PathBuf::from("/tmp/a-images/two.png"),
721 },
722 ];
723
724 let directories = cli_prompt_access_directories(
726 &workspace_folder,
727 &attachments,
728 CliPromptAccessRootMode::WorkspaceThenAttachments,
729 );
730
731 assert_eq!(
733 directories,
734 vec![workspace_folder, PathBuf::from("/tmp/a-images")]
735 );
736 }
737}