1use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use ag_protocol::{
7 ProtocolRequestProfile, ProtocolSchemaInstructionMode,
8 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_transcript_prompt.md", escape = "none")]
22struct ResumeWithTranscriptPromptTemplate<'a> {
23 prompt: &'a str,
25 transcript: &'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_transcript: Option<&'a str>,
40 pub schema_instruction_mode: ProtocolSchemaInstructionMode,
43 pub workspace_root: &'a Path,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub(crate) enum CliPromptAccessRootMode {
52 AttachmentsOnly,
54 WorkspaceThenAttachments,
56}
57
58pub fn prepare_prompt_text(
63 request: PromptPreparationRequest<'_>,
64) -> Result<String, AgentBackendError> {
65 match request.instruction_delivery_mode {
66 InstructionDeliveryMode::BootstrapFull => Ok(protocol_prepend_instructions(
67 request.prompt,
68 request.protocol_profile,
69 request.schema_instruction_mode,
70 request.workspace_root,
71 )),
72 InstructionDeliveryMode::DeltaOnly => Ok(protocol_prepend_refresh_reminder(
73 request.prompt,
74 request.protocol_profile,
75 request.workspace_root,
76 )),
77 InstructionDeliveryMode::BootstrapWithReplay => {
78 let prompt = build_resume_prompt(request.prompt, request.replay_transcript)?;
79
80 Ok(protocol_prepend_instructions(
81 &prompt,
82 request.protocol_profile,
83 request.schema_instruction_mode,
84 request.workspace_root,
85 ))
86 }
87 }
88}
89
90pub(crate) fn build_resume_prompt(
95 prompt: &str,
96 replay_transcript: Option<&str>,
97) -> Result<String, AgentBackendError> {
98 let Some(transcript) = replay_transcript
99 .map(str::trim)
100 .filter(|value| !value.is_empty())
101 else {
102 return Ok(prompt.to_string());
103 };
104
105 let template = ResumeWithTranscriptPromptTemplate { prompt, transcript };
106
107 render_template("resume_with_transcript_prompt.md", &template)
108}
109
110pub(crate) fn build_prompt_stdin_payload(
120 request: BuildCommandRequest<'_>,
121 schema_instruction_mode: ProtocolSchemaInstructionMode,
122 backend_display_name: &str,
123) -> Result<Vec<u8>, AgentBackendError> {
124 let prompt =
125 render_prompt_with_local_images(request.prompt, request.attachments, backend_display_name)?;
126 let prompt = prepare_prompt_text(PromptPreparationRequest {
127 instruction_delivery_mode: if request.request_kind.is_resume() {
128 InstructionDeliveryMode::BootstrapWithReplay
129 } else {
130 InstructionDeliveryMode::BootstrapFull
131 },
132 prompt: &prompt,
133 protocol_profile: request.request_kind.protocol_profile(),
134 replay_transcript: request.replay_transcript,
135 schema_instruction_mode,
136 workspace_root: request.folder,
137 })?;
138
139 Ok(prompt.into_bytes())
140}
141
142pub(crate) fn append_cli_prompt_access_directories(
150 command: &mut Command,
151 workspace_folder: &Path,
152 attachments: &[TurnPromptAttachment],
153 root_mode: CliPromptAccessRootMode,
154) {
155 for directory in cli_prompt_access_directories(workspace_folder, attachments, root_mode) {
156 command.arg("--add-dir").arg(directory);
157 }
158}
159
160pub(crate) fn render_prompt_with_local_images(
169 prompt: &str,
170 attachments: &[TurnPromptAttachment],
171 backend_display_name: &str,
172) -> Result<String, AgentBackendError> {
173 if attachments.is_empty() {
174 return Ok(prompt.to_string());
175 }
176
177 let mut rendered_prompt = String::new();
178
179 for content_part in split_turn_prompt_content(prompt, attachments) {
180 match content_part {
181 TurnPromptContentPart::Text(text) => rendered_prompt.push_str(text),
182 TurnPromptContentPart::Attachment(attachment) => {
183 let attachment_path = attachment_path_for_prompt(backend_display_name, attachment)?;
184 rendered_prompt.push_str(&attachment_path);
185 }
186 TurnPromptContentPart::OrphanAttachment(attachment) => {
187 if !rendered_prompt.is_empty()
188 && rendered_prompt
189 .chars()
190 .last()
191 .is_some_and(|character| !character.is_whitespace())
192 {
193 rendered_prompt.push('\n');
194 }
195
196 rendered_prompt.push_str(&attachment_path_for_prompt(
197 backend_display_name,
198 attachment,
199 )?);
200 rendered_prompt.push('\n');
201 }
202 }
203 }
204
205 Ok(rendered_prompt)
206}
207
208pub(crate) fn cli_prompt_access_directories(
214 workspace_folder: &Path,
215 attachments: &[TurnPromptAttachment],
216 root_mode: CliPromptAccessRootMode,
217) -> Vec<PathBuf> {
218 let mut attachment_directories = attachments
219 .iter()
220 .filter_map(|attachment| attachment.local_image_path.parent())
221 .map(ToOwned::to_owned)
222 .collect::<Vec<_>>();
223 attachment_directories.sort();
224 attachment_directories.dedup();
225
226 if matches!(root_mode, CliPromptAccessRootMode::AttachmentsOnly) {
227 return attachment_directories;
228 }
229
230 attachment_directories
231 .retain(|attachment_directory| attachment_directory.as_path() != workspace_folder);
232
233 let mut workspace_directories = Vec::with_capacity(attachment_directories.len() + 1);
234 workspace_directories.push(workspace_folder.to_path_buf());
235 workspace_directories.extend(attachment_directories);
236
237 workspace_directories
238}
239
240fn attachment_path_for_prompt(
245 backend_display_name: &str,
246 attachment: &TurnPromptAttachment,
247) -> Result<String, AgentBackendError> {
248 attachment
249 .local_image_path
250 .to_str()
251 .map(ToOwned::to_owned)
252 .ok_or_else(|| {
253 AgentBackendError::CommandBuild(format!(
254 "{backend_display_name} prompt image path is not valid UTF-8"
255 ))
256 })
257}
258
259pub fn diff_fence(content: &str) -> String {
268 let mut max_run = 0usize;
269 let mut current_run = 0usize;
270 for character in content.chars() {
271 if character == '`' {
272 current_run += 1;
273 if current_run > max_run {
274 max_run = current_run;
275 }
276 } else {
277 current_run = 0;
278 }
279 }
280
281 let fence_length = std::cmp::max(3, max_run + 1);
282
283 "`".repeat(fence_length)
284}
285
286fn render_template(
289 template_name: &str,
290 template: &impl Template,
291) -> Result<String, AgentBackendError> {
292 let rendered = template.render().map_err(|error| {
293 AgentBackendError::CommandBuild(format!("Failed to render `{template_name}`: {error}"))
294 })?;
295
296 Ok(rendered.trim_end().to_string())
297}
298
299#[cfg(test)]
300mod tests {
301 #[cfg(unix)]
302 use std::ffi::OsString;
303 #[cfg(unix)]
304 use std::os::unix::ffi::OsStringExt;
305 use std::path::PathBuf;
306
307 use super::*;
308
309 fn test_workspace_root() -> &'static Path {
311 Path::new("/tmp/agentty-wt/session-1")
312 }
313
314 #[test]
315 fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
318 let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
320
321 let fence = diff_fence(diff);
323
324 assert_eq!(fence, "```");
326 }
327
328 #[test]
329 fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
333 let diff = "+```\nsample\n+```\n";
335
336 let fence = diff_fence(diff);
338
339 assert_eq!(fence, "````");
341 }
342
343 #[test]
344 fn test_diff_fence_handles_long_backtick_runs() {
347 let diff = "prefix `````diff\ncontent\n`````\n";
349
350 let fence = diff_fence(diff);
352
353 assert_eq!(fence, "``````");
355 }
356
357 #[test]
358 fn test_build_resume_prompt_includes_replay_transcript_and_prompt() {
361 let prompt = "Continue and update tests";
363 let replay_transcript = Some(" previous transcript line \n");
364
365 let resume_prompt =
367 build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
368
369 let normalized_resume_prompt = resume_prompt.split_whitespace().collect::<Vec<_>>();
371 let normalized_resume_prompt = normalized_resume_prompt.join(" ");
372 assert!(resume_prompt.contains("previous transcript line"));
373 assert!(normalized_resume_prompt.contains("Treat the user's new prompt as a follow-up"));
374 assert!(normalized_resume_prompt.contains("changes made during this Agentty session"));
375 assert!(normalized_resume_prompt.contains("preserve unrelated pre-existing work"));
376 assert!(resume_prompt.contains("Continue and update tests"));
377 }
378
379 #[test]
380 fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
383 let prompt = "Follow-up request";
385 let replay_transcript = Some(" ");
386
387 let resume_prompt =
389 build_resume_prompt(prompt, replay_transcript).expect("resume prompt should render");
390
391 assert_eq!(resume_prompt, prompt);
393 }
394
395 #[test]
396 fn test_build_resume_prompt_returns_original_prompt_without_output() {
398 let prompt = "Retry merge";
400
401 let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
403
404 assert_eq!(resume_prompt, prompt);
406 }
407
408 #[test]
409 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
411 let prompt = "Implement feature";
413
414 let rendered_prompt = protocol_prepend_instructions(
416 prompt,
417 ProtocolRequestProfile::SessionTurn,
418 ProtocolSchemaInstructionMode::PromptSchema,
419 test_workspace_root(),
420 );
421
422 assert!(rendered_prompt.contains("File path output requirements:"));
424 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
425 assert!(rendered_prompt.contains("Your workspace root is `/tmp/agentty-wt/session-1`."));
426 assert!(rendered_prompt.contains("Anything outside that"));
427 assert!(rendered_prompt.contains("root is read-only."));
428 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
429 assert!(
430 rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
431 );
432 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
433 assert!(rendered_prompt.contains("Do not run mutating git commands"));
434 assert!(rendered_prompt.contains("Quality check requirements:"));
435 assert!(rendered_prompt.contains("repository-defined quality checks"));
436 let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
437 let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
438 assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
439 assert!(rendered_prompt.contains("full repository test/check suite"));
440 assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
441 assert!(rendered_prompt.contains("Structured response protocol:"));
442 assert!(rendered_prompt.contains("Return a single JSON object"));
443 assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
444 assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
445 assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
446 assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
447 assert!(
448 rendered_prompt
449 .contains("______________________________________________________________________")
450 );
451 assert!(!rendered_prompt.contains("{# task separator #}"));
452 assert!(rendered_prompt.contains("For this session turn"));
453 assert!(normalized_rendered_prompt.contains("Do not create commits"));
454 assert!(normalized_rendered_prompt.contains("suggest creating commits"));
455 assert!(rendered_prompt.contains("summary"));
456 assert!(rendered_prompt.contains("turn"));
457 assert!(rendered_prompt.contains("session"));
458 assert!(rendered_prompt.contains("\"answer\""));
459 assert!(rendered_prompt.contains("\"questions\""));
460 assert!(rendered_prompt.contains("\"title\""));
461 assert!(rendered_prompt.contains("\"description\""));
462 assert!(rendered_prompt.contains("summary"));
463 assert!(rendered_prompt.ends_with(prompt));
464 }
465
466 #[test]
467 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
470 let prompt = "Implement feature";
472
473 let rendered_prompt = protocol_prepend_instructions(
475 prompt,
476 ProtocolRequestProfile::SessionTurn,
477 ProtocolSchemaInstructionMode::TransportSchema,
478 test_workspace_root(),
479 );
480
481 assert!(rendered_prompt.contains("Structured response protocol:"));
483 assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
484 assert!(rendered_prompt.contains("Return a single JSON object"));
485 assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
486 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
487 assert!(rendered_prompt.ends_with(prompt));
488 }
489
490 #[test]
491 fn test_prepend_protocol_instructions_is_idempotent() {
493 let prompt = protocol_prepend_instructions(
495 "Implement feature",
496 ProtocolRequestProfile::SessionTurn,
497 ProtocolSchemaInstructionMode::PromptSchema,
498 test_workspace_root(),
499 );
500
501 let rendered_prompt = protocol_prepend_instructions(
503 &prompt,
504 ProtocolRequestProfile::UtilityPrompt,
505 ProtocolSchemaInstructionMode::TransportSchema,
506 test_workspace_root(),
507 );
508
509 assert_eq!(rendered_prompt, prompt);
511 }
512
513 #[test]
514 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
517 let prompt = "Generate title";
519
520 let rendered_prompt = protocol_prepend_instructions(
522 prompt,
523 ProtocolRequestProfile::UtilityPrompt,
524 ProtocolSchemaInstructionMode::PromptSchema,
525 test_workspace_root(),
526 );
527
528 assert!(rendered_prompt.contains("Structured response protocol:"));
530 assert!(
531 rendered_prompt
532 .contains("______________________________________________________________________")
533 );
534 assert!(rendered_prompt.contains("For this one-shot utility prompt"));
535 assert!(rendered_prompt.contains(r#"{"answer":"...","questions":[],"summary":null}"#));
536 assert!(rendered_prompt.contains("\"summary\""));
537 assert!(rendered_prompt.ends_with(prompt));
538 }
539
540 #[test]
541 fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
544 let request = PromptPreparationRequest {
546 instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
547 prompt: "Continue edits",
548 protocol_profile: ProtocolRequestProfile::SessionTurn,
549 replay_transcript: Some("previous transcript"),
550 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
551 workspace_root: test_workspace_root(),
552 };
553
554 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
556
557 assert!(prepared_prompt.contains("Structured response protocol:"));
559 assert!(prepared_prompt.contains("Workspace isolation requirements:"));
560 assert!(prepared_prompt.contains("previous transcript"));
561 assert!(prepared_prompt.contains(r"\<user_prompt> Continue edits \</user_prompt>"));
562 assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
563 }
564
565 #[test]
566 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
569 let prompt = "Continue the implementation";
571
572 let rendered_prompt = protocol_prepend_refresh_reminder(
574 prompt,
575 ProtocolRequestProfile::SessionTurn,
576 test_workspace_root(),
577 );
578
579 assert!(rendered_prompt.contains("Protocol refresh reminder:"));
581 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
582 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
583 assert!(rendered_prompt.contains("Do not run mutating git commands."));
584 assert!(rendered_prompt.contains("inside the workspace root `/tmp/agentty-wt/session-1`"));
585 assert!(rendered_prompt.contains("anything outside that root is read-only"));
586 assert!(
587 rendered_prompt
588 .contains("______________________________________________________________________")
589 );
590 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
591 assert!(rendered_prompt.ends_with(prompt));
592 }
593
594 #[test]
595 fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
598 let request = PromptPreparationRequest {
600 instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
601 prompt: "Continue edits",
602 protocol_profile: ProtocolRequestProfile::SessionTurn,
603 replay_transcript: Some("previous transcript"),
604 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
605 workspace_root: test_workspace_root(),
606 };
607
608 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
610
611 assert!(prepared_prompt.contains("Protocol refresh reminder:"));
613 assert!(!prepared_prompt.contains("Authoritative JSON Schema:"));
614 assert!(!prepared_prompt.contains("previous transcript"));
615 assert!(prepared_prompt.ends_with("Continue edits"));
616 }
617
618 #[test]
619 fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
622 let attachments = vec![
624 TurnPromptAttachment {
625 placeholder: "[Image #1]".to_string(),
626 local_image_path: PathBuf::from("/tmp/first-image.png"),
627 },
628 TurnPromptAttachment {
629 placeholder: "[Image #2]".to_string(),
630 local_image_path: PathBuf::from("/tmp/second-image.png"),
631 },
632 ];
633
634 let rendered_prompt = render_prompt_with_local_images(
636 "Compare [Image #2] with [Image #1]",
637 &attachments,
638 "TestBackend",
639 )
640 .expect("prompt rendering should succeed");
641
642 assert_eq!(
644 rendered_prompt,
645 "Compare /tmp/second-image.png with /tmp/first-image.png"
646 );
647 }
648
649 #[test]
650 fn test_render_prompt_with_local_images_appends_missing_paths() {
653 let attachments = vec![TurnPromptAttachment {
655 placeholder: "[Image #1]".to_string(),
656 local_image_path: PathBuf::from("/tmp/first-image.png"),
657 }];
658
659 let rendered_prompt =
661 render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
662 .expect("prompt rendering should succeed");
663
664 assert_eq!(
666 rendered_prompt,
667 "Review this change\n/tmp/first-image.png\n"
668 );
669 }
670
671 #[cfg(unix)]
672 #[test]
673 fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
676 let attachments = vec![TurnPromptAttachment {
678 placeholder: "[Image #1]".to_string(),
679 local_image_path: PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f])),
680 }];
681
682 let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
684 .expect_err("prompt rendering should fail");
685
686 assert_eq!(
688 error,
689 AgentBackendError::CommandBuild(
690 "Claude prompt image path is not valid UTF-8".to_string()
691 )
692 );
693 }
694
695 #[test]
696 fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
699 let workspace_folder = PathBuf::from("/tmp/session");
701 let attachments = vec![
702 TurnPromptAttachment {
703 placeholder: "[Image #1]".to_string(),
704 local_image_path: PathBuf::from("/tmp/images-b/two.png"),
705 },
706 TurnPromptAttachment {
707 placeholder: "[Image #2]".to_string(),
708 local_image_path: PathBuf::from("/tmp/images-a/one.png"),
709 },
710 TurnPromptAttachment {
711 placeholder: "[Image #3]".to_string(),
712 local_image_path: PathBuf::from("/tmp/images-a/three.png"),
713 },
714 ];
715
716 let directories = cli_prompt_access_directories(
718 &workspace_folder,
719 &attachments,
720 CliPromptAccessRootMode::AttachmentsOnly,
721 );
722
723 assert_eq!(
725 directories,
726 vec![
727 PathBuf::from("/tmp/images-a"),
728 PathBuf::from("/tmp/images-b")
729 ]
730 );
731 }
732
733 #[test]
734 fn test_cli_prompt_access_directories_keeps_workspace_first() {
737 let workspace_folder = PathBuf::from("/tmp/z-session");
739 let attachments = vec![
740 TurnPromptAttachment {
741 placeholder: "[Image #1]".to_string(),
742 local_image_path: PathBuf::from("/tmp/z-session/one.png"),
743 },
744 TurnPromptAttachment {
745 placeholder: "[Image #2]".to_string(),
746 local_image_path: PathBuf::from("/tmp/a-images/two.png"),
747 },
748 ];
749
750 let directories = cli_prompt_access_directories(
752 &workspace_folder,
753 &attachments,
754 CliPromptAccessRootMode::WorkspaceThenAttachments,
755 );
756
757 assert_eq!(
759 directories,
760 vec![workspace_folder, PathBuf::from("/tmp/a-images")]
761 );
762 }
763}