Skip to main content

ag_protocol/
prompt.rs

1//! Transport-neutral prompt payloads shared by frontends and agent adapters.
2
3use std::fmt;
4use std::path::PathBuf;
5
6/// One local image attachment referenced from a prompt placeholder.
7///
8/// Serialized JSON consumers must match fields by name; object key order is
9/// not part of this transport contract.
10#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
11pub struct TurnPromptAttachment {
12    /// Local file path persisted for transport upload.
13    pub local_image_path: PathBuf,
14    /// Inline placeholder token such as `[Image #1]` used in prompt text.
15    pub placeholder: String,
16}
17
18/// Structured prompt payload for one agent turn.
19#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
20pub struct TurnPrompt {
21    /// Ordered local image attachments referenced by `text`.
22    pub attachments: Vec<TurnPromptAttachment>,
23    /// Prompt text payload, including inline placeholders when present.
24    pub text: String,
25    /// Source classification that controls prompt-only text rewrites.
26    #[serde(default)]
27    pub text_source: TurnPromptTextSource,
28}
29
30impl TurnPrompt {
31    /// Creates a text-only prompt payload.
32    #[must_use]
33    pub fn from_text(text: String) -> Self {
34        Self {
35            attachments: Vec::new(),
36            text,
37            text_source: TurnPromptTextSource::UserPrompt,
38        }
39    }
40
41    /// Creates a generated-data prompt payload that is sent to the agent
42    /// without user prompt `@path` lookup rewriting.
43    #[must_use]
44    pub fn from_agent_data(text: String) -> Self {
45        Self {
46            attachments: Vec::new(),
47            text,
48            text_source: TurnPromptTextSource::AgentData,
49        }
50    }
51
52    /// Returns whether the payload contains no text and no attachments.
53    #[must_use]
54    pub fn is_empty(&self) -> bool {
55        self.text.is_empty() && self.attachments.is_empty()
56    }
57
58    /// Returns whether the payload contains one or more image attachments.
59    #[must_use]
60    pub fn has_attachments(&self) -> bool {
61        !self.attachments.is_empty()
62    }
63
64    /// Returns the local image paths referenced by this prompt payload.
65    pub fn local_image_paths(&self) -> impl Iterator<Item = &PathBuf> {
66        self.attachments
67            .iter()
68            .map(|attachment| &attachment.local_image_path)
69    }
70
71    /// Returns whether the prompt text contains `needle`.
72    #[must_use]
73    pub fn contains(&self, needle: &str) -> bool {
74        self.text.contains(needle)
75    }
76
77    /// Returns whether the prompt text ends with `suffix`.
78    #[must_use]
79    pub fn ends_with(&self, suffix: &str) -> bool {
80        self.text.ends_with(suffix)
81    }
82
83    /// Returns the prompt text as it should be sent to an agent runtime.
84    ///
85    /// User-authored `@path` lookups are rewritten to quoted path tokens for
86    /// transport. Generated agent data is returned unchanged so source content
87    /// such as Python decorators keeps its leading `@`.
88    #[must_use]
89    pub fn agent_text(&self) -> String {
90        match self.text_source {
91            TurnPromptTextSource::UserPrompt => render_prompt_text_for_agent(&self.text),
92            TurnPromptTextSource::AgentData => self.text.clone(),
93        }
94    }
95
96    /// Returns prompt text spans and image attachments in transport order.
97    ///
98    /// Attachments are ordered by their placeholder position in `text`. Any
99    /// attachment whose placeholder no longer exists in the text is appended
100    /// after the trailing text so transports can still serialize the image
101    /// instead of dropping it silently.
102    #[must_use]
103    pub fn content_parts(&self) -> Vec<TurnPromptContentPart<'_>> {
104        split_turn_prompt_content(&self.text, &self.attachments)
105    }
106
107    /// Returns the prompt text as it should be written into persisted
108    /// transcripts.
109    ///
110    /// Inline `[Image #n]` markers are preserved verbatim. If attachment
111    /// metadata somehow survives without its placeholder still present in the
112    /// text, the missing placeholders are appended in attachment order so the
113    /// transcript does not silently become text-only.
114    #[must_use]
115    pub fn transcript_text(&self) -> String {
116        let mut transcript_text = self.text.clone();
117        let missing_placeholders = self
118            .attachments
119            .iter()
120            .filter(|attachment| !self.text.contains(&attachment.placeholder))
121            .map(|attachment| attachment.placeholder.as_str())
122            .collect::<Vec<_>>();
123
124        if missing_placeholders.is_empty() {
125            return transcript_text;
126        }
127
128        if transcript_text
129            .chars()
130            .last()
131            .is_some_and(|character| !character.is_whitespace())
132        {
133            transcript_text.push(' ');
134        }
135
136        transcript_text.push_str(&missing_placeholders.join(" "));
137
138        transcript_text
139    }
140}
141
142impl From<String> for TurnPrompt {
143    fn from(text: String) -> Self {
144        Self::from_text(text)
145    }
146}
147
148impl From<&str> for TurnPrompt {
149    fn from(text: &str) -> Self {
150        Self::from_text(text.to_string())
151    }
152}
153
154impl From<&TurnPrompt> for TurnPrompt {
155    fn from(prompt: &TurnPrompt) -> Self {
156        prompt.clone()
157    }
158}
159
160impl fmt::Display for TurnPrompt {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter.write_str(&self.text)
163    }
164}
165
166impl PartialEq<&str> for TurnPrompt {
167    fn eq(&self, other: &&str) -> bool {
168        self.text == *other
169    }
170}
171
172impl PartialEq<TurnPrompt> for &str {
173    fn eq(&self, other: &TurnPrompt) -> bool {
174        *self == other.text
175    }
176}
177
178/// Source classification for one turn prompt text payload.
179#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
180pub enum TurnPromptTextSource {
181    /// User-authored prompt text where `@path` lookup tokens should be
182    /// converted before transport delivery.
183    #[default]
184    UserPrompt,
185    /// Agent-facing generated data such as utility prompts, protocol repair
186    /// prompts, diffs, and transcript previews that must be sent unchanged.
187    AgentData,
188}
189
190/// Ordered content piece produced when serializing one turn prompt.
191#[derive(Debug, Clone, Copy, Eq, PartialEq)]
192pub enum TurnPromptContentPart<'prompt> {
193    /// One local image attachment referenced from the prompt text.
194    Attachment(&'prompt TurnPromptAttachment),
195    /// One attachment whose placeholder no longer appears in the prompt text.
196    OrphanAttachment(&'prompt TurnPromptAttachment),
197    /// One plain-text span from the prompt.
198    Text(&'prompt str),
199}
200
201/// Rewrites user-entered `@` lookups into quoted agent-facing path tokens.
202///
203/// File and directory lookups like `@path/to/file` are rewritten to
204/// `"path/to/file"`. Non-lookup uses of `@`, including email-like tokens and
205/// lone `@`, are preserved so frontends and persisted transcripts can continue
206/// storing the original user input unchanged.
207#[must_use]
208pub fn render_prompt_text_for_agent(text: &str) -> String {
209    let characters = text.chars().collect::<Vec<char>>();
210    let mut output = String::with_capacity(text.len());
211    let mut index = 0;
212
213    while let Some(&character) = characters.get(index) {
214        if character != '@'
215            || !is_at_mention_boundary(characters.get(index.wrapping_sub(1)).copied())
216            || index + 1 >= characters.len()
217        {
218            output.push(character);
219            index += 1;
220
221            continue;
222        }
223
224        let mut scan_index = index + 1;
225        while scan_index < characters.len() && is_at_mention_query_character(characters[scan_index])
226        {
227            scan_index += 1;
228        }
229
230        if scan_index == index + 1 {
231            output.push(character);
232            index += 1;
233
234            continue;
235        }
236
237        output.push('"');
238        output.extend(characters[index + 1..scan_index].iter());
239        output.push('"');
240        index = scan_index;
241    }
242
243    output
244}
245
246/// Splits prompt text into ordered text and attachment parts for transport
247/// serialization.
248#[must_use]
249pub fn split_turn_prompt_content<'prompt>(
250    text: &'prompt str,
251    attachments: &'prompt [TurnPromptAttachment],
252) -> Vec<TurnPromptContentPart<'prompt>> {
253    if attachments.is_empty() {
254        return vec![TurnPromptContentPart::Text(text)];
255    }
256
257    let mut ordered_attachments = attachments.iter().collect::<Vec<_>>();
258    ordered_attachments
259        // Attachments without an inline placeholder are appended after the
260        // text-bearing attachments so transcript order stays deterministic.
261        .sort_by_key(|attachment| text.find(&attachment.placeholder).unwrap_or(usize::MAX));
262
263    let mut content_parts = Vec::new();
264    let mut orphan_attachments = Vec::new();
265    let mut remaining_text = text;
266
267    for attachment in ordered_attachments {
268        if let Some(placeholder_index) = remaining_text.find(&attachment.placeholder) {
269            let (before_placeholder, after_placeholder) =
270                remaining_text.split_at(placeholder_index);
271
272            if !before_placeholder.is_empty() {
273                content_parts.push(TurnPromptContentPart::Text(before_placeholder));
274            }
275
276            content_parts.push(TurnPromptContentPart::Attachment(attachment));
277            remaining_text = &after_placeholder[attachment.placeholder.len()..];
278
279            continue;
280        }
281
282        orphan_attachments.push(attachment);
283    }
284
285    if !remaining_text.is_empty() {
286        content_parts.push(TurnPromptContentPart::Text(remaining_text));
287    }
288
289    content_parts.extend(
290        orphan_attachments
291            .into_iter()
292            .map(TurnPromptContentPart::OrphanAttachment),
293    );
294
295    content_parts
296}
297
298/// Returns whether the character before `@` starts a file lookup token.
299fn is_at_mention_boundary(previous_character: Option<char>) -> bool {
300    previous_character.is_none_or(|character| {
301        character.is_whitespace() || is_at_mention_opening_delimiter(character)
302    })
303}
304
305/// Returns whether `character` can appear inside an active `@` lookup token.
306fn is_at_mention_query_character(character: char) -> bool {
307    character.is_alphanumeric() || matches!(character, '/' | '.' | '_' | '-')
308}
309
310/// Returns whether `character` is an opening delimiter that can precede `@`.
311fn is_at_mention_opening_delimiter(character: char) -> bool {
312    matches!(character, '(' | '[' | '{')
313}
314
315#[cfg(test)]
316mod tests {
317    use std::path::PathBuf;
318
319    use super::*;
320
321    #[test]
322    /// Ensures attachment JSON accepts either object key order while
323    /// preserving the complete named-field wire shape.
324    fn test_turn_prompt_attachment_json_uses_named_fields_independent_of_order() {
325        // Arrange
326        let attachment = TurnPromptAttachment {
327            local_image_path: PathBuf::from("/tmp/image-1.png"),
328            placeholder: "[Image #1]".to_string(),
329        };
330        let alternate_order_json =
331            r#"{"placeholder":"[Image #1]","local_image_path":"/tmp/image-1.png"}"#;
332
333        // Act
334        let deserialized_attachment =
335            serde_json::from_str::<TurnPromptAttachment>(alternate_order_json)
336                .expect("attachment JSON should deserialize by field name");
337        let serialized_value =
338            serde_json::to_value(&attachment).expect("attachment should serialize");
339
340        // Assert
341        assert_eq!(deserialized_attachment, attachment);
342        assert_eq!(
343            serialized_value,
344            serde_json::json!({
345                "local_image_path": "/tmp/image-1.png",
346                "placeholder": "[Image #1]",
347            })
348        );
349    }
350
351    #[test]
352    /// Ensures prompt text comparisons work with string slices on either side.
353    fn test_turn_prompt_compares_with_str_in_both_directions() {
354        // Arrange
355        let prompt = TurnPrompt::from("Review this change");
356
357        // Act
358        let prompt_matches = prompt == "Review this change";
359        let text_matches = "Review this change" == prompt;
360
361        // Assert
362        assert!(prompt_matches);
363        assert!(text_matches);
364    }
365
366    #[test]
367    /// Ensures transcript text keeps inline image placeholders unchanged.
368    fn test_turn_prompt_transcript_text_keeps_inline_placeholders() {
369        // Arrange
370        let prompt = TurnPrompt {
371            attachments: vec![TurnPromptAttachment {
372                local_image_path: PathBuf::from("/tmp/image-1.png"),
373                placeholder: "[Image #1]".to_string(),
374            }],
375            text: "Review [Image #1] carefully".to_string(),
376            text_source: TurnPromptTextSource::UserPrompt,
377        };
378
379        // Act
380        let transcript_text = prompt.transcript_text();
381
382        // Assert
383        assert_eq!(transcript_text, "Review [Image #1] carefully");
384    }
385
386    #[test]
387    /// Ensures agent-bound prompt text rewrites raw `@` lookups without
388    /// mutating transcript-facing text.
389    fn test_turn_prompt_agent_text_rewrites_user_at_lookups() {
390        // Arrange
391        let prompt = TurnPrompt::from("Review @src/main.rs and person@example.com");
392
393        // Act
394        let agent_text = prompt.agent_text();
395        let transcript_text = prompt.transcript_text();
396
397        // Assert
398        assert_eq!(agent_text, "Review \"src/main.rs\" and person@example.com");
399        assert_eq!(
400            transcript_text,
401            "Review @src/main.rs and person@example.com"
402        );
403    }
404
405    #[test]
406    /// Ensures generated agent data bypasses user prompt `@` lookup rewriting.
407    fn test_turn_prompt_agent_text_preserves_agent_data_at_tokens() {
408        // Arrange
409        let prompt = TurnPrompt::from_agent_data(
410            "Diff:\n```diff\n+@dataclass\n+class Config:\n+    pass\n```".to_string(),
411        );
412
413        // Act
414        let agent_text = prompt.agent_text();
415
416        // Assert
417        assert!(agent_text.contains("+@dataclass"));
418        assert!(!agent_text.contains("+\"dataclass\""));
419    }
420
421    #[test]
422    /// Ensures transcript text appends any attachment markers missing from the
423    /// text payload.
424    fn test_turn_prompt_transcript_text_appends_missing_placeholders() {
425        // Arrange
426        let prompt = TurnPrompt {
427            attachments: vec![
428                TurnPromptAttachment {
429                    local_image_path: PathBuf::from("/tmp/image-1.png"),
430                    placeholder: "[Image #1]".to_string(),
431                },
432                TurnPromptAttachment {
433                    local_image_path: PathBuf::from("/tmp/image-2.png"),
434                    placeholder: "[Image #2]".to_string(),
435                },
436            ],
437            text: "Review".to_string(),
438            text_source: TurnPromptTextSource::UserPrompt,
439        };
440
441        // Act
442        let transcript_text = prompt.transcript_text();
443
444        // Assert
445        assert_eq!(transcript_text, "Review [Image #1] [Image #2]");
446    }
447
448    #[test]
449    /// Ensures prompt content without attachments remains one text part.
450    fn test_split_turn_prompt_content_returns_text_without_attachments() {
451        // Arrange
452        let text = "Review this change";
453
454        // Act
455        let content_parts = split_turn_prompt_content(text, &[]);
456
457        // Assert
458        assert_eq!(content_parts, vec![TurnPromptContentPart::Text(text)]);
459    }
460
461    #[test]
462    /// Ensures prompt content parts follow placeholder order and keep orphaned
463    /// attachments at the end.
464    fn test_split_turn_prompt_content_orders_placeholders_and_appends_orphans() {
465        // Arrange
466        let attachments = vec![
467            TurnPromptAttachment {
468                local_image_path: PathBuf::from("/tmp/image-1.png"),
469                placeholder: "[Image #1]".to_string(),
470            },
471            TurnPromptAttachment {
472                local_image_path: PathBuf::from("/tmp/image-2.png"),
473                placeholder: "[Image #2]".to_string(),
474            },
475            TurnPromptAttachment {
476                local_image_path: PathBuf::from("/tmp/image-3.png"),
477                placeholder: "[Image #3]".to_string(),
478            },
479        ];
480
481        // Act
482        let content_parts =
483            split_turn_prompt_content("Compare [Image #2] with [Image #1] now", &attachments);
484
485        // Assert
486        assert_eq!(
487            content_parts,
488            vec![
489                TurnPromptContentPart::Text("Compare "),
490                TurnPromptContentPart::Attachment(&attachments[1]),
491                TurnPromptContentPart::Text(" with "),
492                TurnPromptContentPart::Attachment(&attachments[0]),
493                TurnPromptContentPart::Text(" now"),
494                TurnPromptContentPart::OrphanAttachment(&attachments[2]),
495            ]
496        );
497    }
498}