Skip to main content

protocol_droid/domain/ir/
prompt.rs

1//! The messages a `prompts/get` answers with.
2//!
3//! # Why messages are not part of a `Prompt`
4//!
5//! `prompts/list` describes what a prompt *is* — its name, its arguments, its
6//! description — and `prompts/get` produces what the prompt *says* for one set
7//! of arguments. Those are separate methods with separate answers, and the
8//! listing has no field to carry messages: the vendored `Prompt` definition for
9//! `2025-06-18` has `name`, `title`, `description`, `arguments`, `icons` and
10//! `_meta`, and nothing else.
11//!
12//! So the two live in two types. A `Prompt` value that could also hold
13//! messages would be a shape the wire cannot express, and the registry would
14//! be storing something no peer could read back.
15
16use crate::domain::ir::content::{Content, Role};
17
18/// One message of a `prompts/get` result.
19#[derive(Debug, Clone, PartialEq)]
20pub struct PromptMessage {
21    /// Who the message is meant for.
22    pub role: Role,
23    /// What it says.
24    ///
25    /// A `Content` rather than a `String`, because the schema types this field
26    /// as `ContentBlock` — the same union a tool result carries — and the
27    /// suite's image-bearing prompt scenario depends on that. A prompt cannot
28    /// embed an image if its message is a string.
29    pub content: Content,
30}
31
32impl PromptMessage {
33    /// A message carrying one content block.
34    #[must_use]
35    pub const fn new(role: Role, content: Content) -> Self {
36        Self { role, content }
37    }
38}
39
40/// What `prompts/get` answers with.
41///
42/// Built from the messages rather than carrying them, so the description has
43/// one obvious source: the prompt's own, already registered for `prompts/list`.
44/// Reading it from here instead would leave two places that can disagree.
45#[derive(Debug, Clone, PartialEq)]
46pub struct GetPromptResult {
47    /// The prompt's own description, echoed back.
48    pub description: Option<String>,
49    /// The messages, in order.
50    pub messages: Vec<PromptMessage>,
51}
52
53impl GetPromptResult {
54    /// The messages, with no description.
55    #[must_use]
56    pub const fn new(messages: Vec<PromptMessage>) -> Self {
57        Self {
58            description: None,
59            messages,
60        }
61    }
62
63    /// The same result, with a description.
64    #[must_use]
65    pub fn with_description(mut self, description: impl Into<String>) -> Self {
66        self.description = Some(description.into());
67        self
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::PromptMessage;
74    use crate::domain::ir::content::{Content, TextContent};
75    use crate::domain::ir::{GetPromptResult, Role};
76
77    #[test]
78    fn a_result_carries_its_messages_and_no_description_by_default() -> Result<(), String> {
79        let result = GetPromptResult::new(vec![PromptMessage::new(
80            Role::User,
81            Content::Text(TextContent::new("hello")),
82        )]);
83
84        match result.description {
85            None => {}
86            other => return Err(format!("a result with no description carried {other:?}")),
87        }
88        if result.messages.len() != 1 {
89            return Err(format!("expected 1 message, got {}", result.messages.len()));
90        }
91        Ok(())
92    }
93
94    #[test]
95    fn a_description_is_kept_when_there_is_one() -> Result<(), String> {
96        let result = GetPromptResult::new(vec![PromptMessage::new(
97            Role::Assistant,
98            Content::Text(TextContent::new("hi")),
99        )])
100        .with_description("greets");
101
102        match result.description.as_deref() {
103            Some("greets") => Ok(()),
104            other => Err(format!("the description was {other:?}")),
105        }
106    }
107
108    #[test]
109    fn a_message_keeps_the_block_it_was_given() -> Result<(), String> {
110        let message = PromptMessage::new(
111            Role::User,
112            Content::Text(TextContent::new("an image, not a string")),
113        );
114
115        match (&message.role, &message.content) {
116            (Role::User, Content::Text(text)) if text.text == "an image, not a string" => Ok(()),
117            other => Err(format!("the message became {other:?}")),
118        }
119    }
120}