use std::path::{Path, PathBuf};
use std::process::Command;
pub use ag_protocol::ProtocolSchemaInstructionMode;
use ag_protocol::{
ProtocolRequestProfile, prepend_protocol_instructions as protocol_prepend_instructions,
prepend_protocol_refresh_reminder as protocol_prepend_refresh_reminder,
};
use askama::Template;
use super::backend::{AgentBackendError, BuildCommandRequest};
use super::instruction::InstructionDeliveryMode;
use crate::model::turn_prompt::{
TurnPromptAttachment, TurnPromptContentPart, split_turn_prompt_content,
};
#[derive(Template)]
#[template(path = "resume_with_session_output_prompt.md", escape = "none")]
struct ResumeWithSessionOutputPromptTemplate<'a> {
prompt: &'a str,
session_output: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PromptPreparationRequest<'a> {
pub instruction_delivery_mode: InstructionDeliveryMode,
pub prompt: &'a str,
pub protocol_profile: ProtocolRequestProfile,
pub replay_session_output: Option<&'a str>,
pub schema_instruction_mode: ProtocolSchemaInstructionMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CliPromptAccessRootMode {
AttachmentsOnly,
WorkspaceThenAttachments,
}
pub fn prepare_prompt_text(
request: PromptPreparationRequest<'_>,
) -> Result<String, AgentBackendError> {
match request.instruction_delivery_mode {
InstructionDeliveryMode::BootstrapFull => Ok(protocol_prepend_instructions(
request.prompt,
request.protocol_profile,
request.schema_instruction_mode,
)),
InstructionDeliveryMode::DeltaOnly => Ok(protocol_prepend_refresh_reminder(
request.prompt,
request.protocol_profile,
)),
InstructionDeliveryMode::BootstrapWithReplay => {
let prompt = build_resume_prompt(request.prompt, request.replay_session_output)?;
Ok(protocol_prepend_instructions(
&prompt,
request.protocol_profile,
request.schema_instruction_mode,
))
}
}
}
pub(crate) fn build_resume_prompt(
prompt: &str,
session_output: Option<&str>,
) -> Result<String, AgentBackendError> {
let Some(session_output) = session_output
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(prompt.to_string());
};
let template = ResumeWithSessionOutputPromptTemplate {
prompt,
session_output,
};
render_template("resume_with_session_output_prompt.md", &template)
}
pub(crate) fn build_prompt_stdin_payload(
request: BuildCommandRequest<'_>,
schema_instruction_mode: ProtocolSchemaInstructionMode,
backend_display_name: &str,
) -> Result<Vec<u8>, AgentBackendError> {
let prompt =
render_prompt_with_local_images(request.prompt, request.attachments, backend_display_name)?;
let prompt = prepare_prompt_text(PromptPreparationRequest {
instruction_delivery_mode: if request.request_kind.is_resume() {
InstructionDeliveryMode::BootstrapWithReplay
} else {
InstructionDeliveryMode::BootstrapFull
},
prompt: &prompt,
protocol_profile: request.request_kind.protocol_profile(),
replay_session_output: request.request_kind.session_output(),
schema_instruction_mode,
})?;
Ok(prompt.into_bytes())
}
pub(crate) fn append_cli_prompt_access_directories(
command: &mut Command,
workspace_folder: &Path,
attachments: &[TurnPromptAttachment],
root_mode: CliPromptAccessRootMode,
) {
for directory in cli_prompt_access_directories(workspace_folder, attachments, root_mode) {
command.arg("--add-dir").arg(directory);
}
}
pub(crate) fn render_prompt_with_local_images(
prompt: &str,
attachments: &[TurnPromptAttachment],
backend_display_name: &str,
) -> Result<String, AgentBackendError> {
if attachments.is_empty() {
return Ok(prompt.to_string());
}
let mut rendered_prompt = String::new();
for content_part in split_turn_prompt_content(prompt, attachments) {
match content_part {
TurnPromptContentPart::Text(text) => rendered_prompt.push_str(text),
TurnPromptContentPart::Attachment(attachment) => {
let attachment_path = attachment_path_for_prompt(backend_display_name, attachment)?;
rendered_prompt.push_str(&attachment_path);
}
TurnPromptContentPart::OrphanAttachment(attachment) => {
if !rendered_prompt.is_empty()
&& rendered_prompt
.chars()
.last()
.is_some_and(|character| !character.is_whitespace())
{
rendered_prompt.push('\n');
}
rendered_prompt.push_str(&attachment_path_for_prompt(
backend_display_name,
attachment,
)?);
rendered_prompt.push('\n');
}
}
}
Ok(rendered_prompt)
}
pub(crate) fn cli_prompt_access_directories(
workspace_folder: &Path,
attachments: &[TurnPromptAttachment],
root_mode: CliPromptAccessRootMode,
) -> Vec<PathBuf> {
let mut attachment_directories = attachments
.iter()
.filter_map(|attachment| attachment.local_image_path.parent())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
attachment_directories.sort();
attachment_directories.dedup();
if matches!(root_mode, CliPromptAccessRootMode::AttachmentsOnly) {
return attachment_directories;
}
attachment_directories
.retain(|attachment_directory| attachment_directory.as_path() != workspace_folder);
let mut workspace_directories = Vec::with_capacity(attachment_directories.len() + 1);
workspace_directories.push(workspace_folder.to_path_buf());
workspace_directories.extend(attachment_directories);
workspace_directories
}
fn attachment_path_for_prompt(
backend_display_name: &str,
attachment: &TurnPromptAttachment,
) -> Result<String, AgentBackendError> {
attachment
.local_image_path
.to_str()
.map(ToOwned::to_owned)
.ok_or_else(|| {
AgentBackendError::CommandBuild(format!(
"{backend_display_name} prompt image path is not valid UTF-8"
))
})
}
pub fn diff_fence(content: &str) -> String {
let mut max_run = 0usize;
let mut current_run = 0usize;
for character in content.chars() {
if character == '`' {
current_run += 1;
if current_run > max_run {
max_run = current_run;
}
} else {
current_run = 0;
}
}
let fence_length = std::cmp::max(3, max_run + 1);
"`".repeat(fence_length)
}
fn render_template(
template_name: &str,
template: &impl Template,
) -> Result<String, AgentBackendError> {
let rendered = template.render().map_err(|error| {
AgentBackendError::CommandBuild(format!("Failed to render `{template_name}`: {error}"))
})?;
Ok(rendered.trim_end().to_string())
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
use std::path::PathBuf;
use super::*;
#[test]
fn test_diff_fence_returns_minimum_three_backticks_for_plain_diff() {
let diff = "diff --git a/a.rs b/a.rs\n+fn main() {}\n";
let fence = diff_fence(diff);
assert_eq!(fence, "```");
}
#[test]
fn test_diff_fence_exceeds_longest_backtick_run_in_diff() {
let diff = "+```\nsample\n+```\n";
let fence = diff_fence(diff);
assert_eq!(fence, "````");
}
#[test]
fn test_diff_fence_handles_long_backtick_runs() {
let diff = "prefix `````diff\ncontent\n`````\n";
let fence = diff_fence(diff);
assert_eq!(fence, "``````");
}
#[test]
fn test_build_resume_prompt_includes_session_output_and_prompt() {
let prompt = "Continue and update tests";
let session_output = Some(" previous output line \n");
let resume_prompt =
build_resume_prompt(prompt, session_output).expect("resume prompt should render");
let normalized_resume_prompt = resume_prompt.split_whitespace().collect::<Vec<_>>();
let normalized_resume_prompt = normalized_resume_prompt.join(" ");
assert!(resume_prompt.contains("previous output line"));
assert!(normalized_resume_prompt.contains("Treat the user's new prompt as a follow-up"));
assert!(normalized_resume_prompt.contains("changes made during this Agentty session"));
assert!(normalized_resume_prompt.contains("preserve unrelated pre-existing work"));
assert!(resume_prompt.contains("Continue and update tests"));
}
#[test]
fn test_build_resume_prompt_returns_original_prompt_when_output_is_blank() {
let prompt = "Follow-up request";
let session_output = Some(" ");
let resume_prompt =
build_resume_prompt(prompt, session_output).expect("resume prompt should render");
assert_eq!(resume_prompt, prompt);
}
#[test]
fn test_build_resume_prompt_returns_original_prompt_without_output() {
let prompt = "Retry merge";
let resume_prompt = build_resume_prompt(prompt, None).expect("resume prompt should render");
assert_eq!(resume_prompt, prompt);
}
#[test]
fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
let prompt = "Implement feature";
let rendered_prompt = protocol_prepend_instructions(
prompt,
ProtocolRequestProfile::SessionTurn,
ProtocolSchemaInstructionMode::PromptSchema,
);
assert!(rendered_prompt.contains("File path output requirements:"));
assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
assert!(
rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
);
assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
assert!(rendered_prompt.contains("Do not run mutating git commands"));
assert!(rendered_prompt.contains("Quality check requirements:"));
assert!(rendered_prompt.contains("repository-defined quality checks"));
let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
assert!(rendered_prompt.contains("full repository test/check suite"));
assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
assert!(rendered_prompt.contains("Structured response protocol:"));
assert!(rendered_prompt.contains("Return a single JSON object"));
assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
assert!(
rendered_prompt
.contains("______________________________________________________________________")
);
assert!(!rendered_prompt.contains("{# task separator #}"));
assert!(rendered_prompt.contains("For this session turn"));
assert!(normalized_rendered_prompt.contains("Do not create commits"));
assert!(normalized_rendered_prompt.contains("suggest creating commits"));
assert!(rendered_prompt.contains("summary"));
assert!(rendered_prompt.contains("turn"));
assert!(rendered_prompt.contains("session"));
assert!(rendered_prompt.contains("\"answer\""));
assert!(rendered_prompt.contains("\"questions\""));
assert!(rendered_prompt.contains("\"title\""));
assert!(rendered_prompt.contains("\"description\""));
assert!(rendered_prompt.contains("summary"));
assert!(rendered_prompt.ends_with(prompt));
}
#[test]
fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
let prompt = "Implement feature";
let rendered_prompt = protocol_prepend_instructions(
prompt,
ProtocolRequestProfile::SessionTurn,
ProtocolSchemaInstructionMode::TransportSchema,
);
assert!(rendered_prompt.contains("Structured response protocol:"));
assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
assert!(rendered_prompt.contains("Return a single JSON object"));
assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
assert!(rendered_prompt.ends_with(prompt));
}
#[test]
fn test_prepend_protocol_instructions_is_idempotent() {
let prompt = protocol_prepend_instructions(
"Implement feature",
ProtocolRequestProfile::SessionTurn,
ProtocolSchemaInstructionMode::PromptSchema,
);
let rendered_prompt = protocol_prepend_instructions(
&prompt,
ProtocolRequestProfile::UtilityPrompt,
ProtocolSchemaInstructionMode::TransportSchema,
);
assert_eq!(rendered_prompt, prompt);
}
#[test]
fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
let prompt = "Generate title";
let rendered_prompt = protocol_prepend_instructions(
prompt,
ProtocolRequestProfile::UtilityPrompt,
ProtocolSchemaInstructionMode::PromptSchema,
);
assert!(rendered_prompt.contains("Structured response protocol:"));
assert!(
rendered_prompt
.contains("______________________________________________________________________")
);
assert!(rendered_prompt.contains("For this one-shot utility prompt"));
assert!(rendered_prompt.contains(r#"{"answer":"...","questions":[],"summary":null}"#));
assert!(rendered_prompt.contains("\"summary\""));
assert!(rendered_prompt.ends_with(prompt));
}
#[test]
fn test_prepare_prompt_text_applies_replay_and_protocol_instructions() {
let request = PromptPreparationRequest {
instruction_delivery_mode: InstructionDeliveryMode::BootstrapWithReplay,
prompt: "Continue edits",
protocol_profile: ProtocolRequestProfile::SessionTurn,
replay_session_output: Some("previous output"),
schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
};
let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
assert!(prepared_prompt.contains("Structured response protocol:"));
assert!(prepared_prompt.contains("previous output"));
assert!(prepared_prompt.contains(r"\<user_prompt> Continue edits \</user_prompt>"));
assert!(prepared_prompt.ends_with(r"\</user_prompt>"));
}
#[test]
fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
let prompt = "Continue the implementation";
let rendered_prompt =
protocol_prepend_refresh_reminder(prompt, ProtocolRequestProfile::SessionTurn);
assert!(rendered_prompt.contains("Protocol refresh reminder:"));
assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
assert!(rendered_prompt.contains("Do not run mutating git commands."));
assert!(
rendered_prompt
.contains("______________________________________________________________________")
);
assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
assert!(rendered_prompt.ends_with(prompt));
}
#[test]
fn test_prepare_prompt_text_uses_delta_only_refresh_mode() {
let request = PromptPreparationRequest {
instruction_delivery_mode: InstructionDeliveryMode::DeltaOnly,
prompt: "Continue edits",
protocol_profile: ProtocolRequestProfile::SessionTurn,
replay_session_output: Some("previous output"),
schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
};
let prepared_prompt = prepare_prompt_text(request).expect("prompt should render");
assert!(prepared_prompt.contains("Protocol refresh reminder:"));
assert!(!prepared_prompt.contains("Authoritative JSON Schema:"));
assert!(!prepared_prompt.contains("previous output"));
assert!(prepared_prompt.ends_with("Continue edits"));
}
#[test]
fn test_render_prompt_with_local_images_replaces_placeholders_in_order() {
let attachments = vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/first-image.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/second-image.png"),
},
];
let rendered_prompt = render_prompt_with_local_images(
"Compare [Image #2] with [Image #1]",
&attachments,
"TestBackend",
)
.expect("prompt rendering should succeed");
assert_eq!(
rendered_prompt,
"Compare /tmp/second-image.png with /tmp/first-image.png"
);
}
#[test]
fn test_render_prompt_with_local_images_appends_missing_paths() {
let attachments = vec![TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/first-image.png"),
}];
let rendered_prompt =
render_prompt_with_local_images("Review this change", &attachments, "TestBackend")
.expect("prompt rendering should succeed");
assert_eq!(
rendered_prompt,
"Review this change\n/tmp/first-image.png\n"
);
}
#[cfg(unix)]
#[test]
fn test_render_prompt_with_local_images_rejects_non_utf8_paths() {
let attachments = vec![TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f])),
}];
let error = render_prompt_with_local_images("Review [Image #1]", &attachments, "Claude")
.expect_err("prompt rendering should fail");
assert_eq!(
error,
AgentBackendError::CommandBuild(
"Claude prompt image path is not valid UTF-8".to_string()
)
);
}
#[test]
fn test_cli_prompt_access_directories_deduplicates_attachment_directories() {
let workspace_folder = PathBuf::from("/tmp/session");
let attachments = vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/images-b/two.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/images-a/one.png"),
},
TurnPromptAttachment {
placeholder: "[Image #3]".to_string(),
local_image_path: PathBuf::from("/tmp/images-a/three.png"),
},
];
let directories = cli_prompt_access_directories(
&workspace_folder,
&attachments,
CliPromptAccessRootMode::AttachmentsOnly,
);
assert_eq!(
directories,
vec![
PathBuf::from("/tmp/images-a"),
PathBuf::from("/tmp/images-b")
]
);
}
#[test]
fn test_cli_prompt_access_directories_keeps_workspace_first() {
let workspace_folder = PathBuf::from("/tmp/z-session");
let attachments = vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/z-session/one.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/a-images/two.png"),
},
];
let directories = cli_prompt_access_directories(
&workspace_folder,
&attachments,
CliPromptAccessRootMode::WorkspaceThenAttachments,
);
assert_eq!(
directories,
vec![workspace_folder, PathBuf::from("/tmp/a-images")]
);
}
}