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