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(r#"{"answer":"...","questions":[],"summary":null}"#));
534 assert!(rendered_prompt.contains("\"summary\""));
535 assert!(rendered_prompt.ends_with(prompt));
536 }
537
538 #[test]
539 fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
542 let request = PromptPreparationRequest {
544 instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
545 prompt: "Continue edits",
546 protocol_profile: ProtocolRequestProfile::SessionTurn,
547 replay_transcript: Some("previous transcript"),
548 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
549 workspace_root: test_workspace_root(),
550 };
551
552 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
554
555 assert!(prepared_prompt.contains("Structured response protocol:"));
557 assert!(prepared_prompt.contains("Workspace isolation requirements:"));
558 assert!(prepared_prompt.contains("previous transcript"));
559 assert!(prepared_prompt.contains(r"\<user_prompt> Continue edits \</user_prompt>"));
560 assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
561 }
562
563 #[test]
564 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
567 let prompt = "Continue the implementation";
569
570 let rendered_prompt = protocol_prepend_refresh_reminder(
572 prompt,
573 ProtocolRequestProfile::SessionTurn,
574 test_workspace_root(),
575 );
576
577 assert!(rendered_prompt.contains("Protocol refresh reminder:"));
579 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
580 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
581 assert!(rendered_prompt.contains("Do not run mutating git commands."));
582 assert!(rendered_prompt.contains("inside the workspace root `/tmp/agentty-wt/session-1`"));
583 assert!(rendered_prompt.contains("anything outside that root is read-only"));
584 assert!(
585 rendered_prompt
586 .contains("______________________________________________________________________")
587 );
588 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
589 assert!(rendered_prompt.ends_with(prompt));
590 }
591
592 #[test]
593 fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
596 let request = PromptPreparationRequest {
598 instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
599 prompt: "Continue edits",
600 protocol_profile: ProtocolRequestProfile::SessionTurn,
601 replay_transcript: Some("previous transcript"),
602 schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
603 workspace_root: test_workspace_root(),
604 };
605
606 let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
608
609 assert!(prepared_prompt.contains("Protocol refresh reminder:"));
611 assert!(!prepared_prompt.contains("Authoritative JSON Schema:"));
612 assert!(!prepared_prompt.contains("previous transcript"));
613 assert!(prepared_prompt.ends_with("Continue edits"));
614 }
615
616 #[test]
617 fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
620 let attachments = vec![
622 TurnPromptAttachment {
623 placeholder: "[Image #1]".to_string(),
624 local_image_path: PathBuf::from("/tmp/first-image.png"),
625 },
626 TurnPromptAttachment {
627 placeholder: "[Image #2]".to_string(),
628 local_image_path: PathBuf::from("/tmp/second-image.png"),
629 },
630 ];
631
632 let rendered_prompt = render_prompt_with_local_images(
634 "Compare [Image #2] with [Image #1]",
635 &attachments,
636 "TestBackend",
637 )
638 .expect("prompt rendering should succeed");
639
640 assert_eq!(
642 rendered_prompt,
643 "Compare /tmp/second-image.png with /tmp/first-image.png"
644 );
645 }
646
647 #[test]
648 fn test_render_prompt_with_local_images_appends_missing_paths() {
651 let attachments = vec![TurnPromptAttachment {
653 placeholder: "[Image #1]".to_string(),
654 local_image_path: PathBuf::from("/tmp/first-image.png"),
655 }];
656
657 let rendered_prompt =
659 render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
660 .expect("prompt rendering should succeed");
661
662 assert_eq!(
664 rendered_prompt,
665 "Review this change\n/tmp/first-image.png\n"
666 );
667 }
668
669 #[cfg(unix)]
670 #[test]
671 fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
674 let attachments = vec![TurnPromptAttachment {
676 placeholder: "[Image #1]".to_string(),
677 local_image_path: PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f])),
678 }];
679
680 let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
682 .expect_err("prompt rendering should fail");
683
684 assert_eq!(
686 error,
687 AgentBackendError::CommandBuild(
688 "Claude prompt image path is not valid UTF-8".to_string()
689 )
690 );
691 }
692
693 #[test]
694 fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
697 let workspace_folder = PathBuf::from("/tmp/session");
699 let attachments = vec![
700 TurnPromptAttachment {
701 placeholder: "[Image #1]".to_string(),
702 local_image_path: PathBuf::from("/tmp/images-b/two.png"),
703 },
704 TurnPromptAttachment {
705 placeholder: "[Image #2]".to_string(),
706 local_image_path: PathBuf::from("/tmp/images-a/one.png"),
707 },
708 TurnPromptAttachment {
709 placeholder: "[Image #3]".to_string(),
710 local_image_path: PathBuf::from("/tmp/images-a/three.png"),
711 },
712 ];
713
714 let directories = cli_prompt_access_directories(
716 &workspace_folder,
717 &attachments,
718 CliPromptAccessRootMode::AttachmentsOnly,
719 );
720
721 assert_eq!(
723 directories,
724 vec![
725 PathBuf::from("/tmp/images-a"),
726 PathBuf::from("/tmp/images-b")
727 ]
728 );
729 }
730
731 #[test]
732 fn test_cli_prompt_access_directories_keeps_workspace_first() {
735 let workspace_folder = PathBuf::from("/tmp/z-session");
737 let attachments = vec![
738 TurnPromptAttachment {
739 placeholder: "[Image #1]".to_string(),
740 local_image_path: PathBuf::from("/tmp/z-session/one.png"),
741 },
742 TurnPromptAttachment {
743 placeholder: "[Image #2]".to_string(),
744 local_image_path: PathBuf::from("/tmp/a-images/two.png"),
745 },
746 ];
747
748 let directories = cli_prompt_access_directories(
750 &workspace_folder,
751 &attachments,
752 CliPromptAccessRootMode::WorkspaceThenAttachments,
753 );
754
755 assert_eq!(
757 directories,
758 vec![workspace_folder, PathBuf::from("/tmp/a-images")]
759 );
760 }
761}