Skip to main content

acp_utils/conversation/
items.rs

1use crate::content::{display_content_blocks, map_content_blocks_to_text};
2use agent_client_protocol::schema::v2 as acp;
3use schemars::JsonSchema;
4use serde::Serialize;
5use std::borrow::Cow;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use super::tool_calls::ToolCall;
9
10/// Id for one generation of a [`super::Conversation`]'s items.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
12pub struct ConversationId(u64);
13
14/// Id for an item within one [`ConversationId`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, JsonSchema)]
16pub struct ConversationItemId(pub(super) u64);
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, JsonSchema)]
19pub struct Revision(u64);
20
21impl Revision {
22    pub fn value(self) -> u64 {
23        self.0
24    }
25}
26
27/// Whether an item can still change. Items are sealed once their turn ends or,
28/// for a tool call, once it and its sub-agents reach a terminal status.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
30#[serde(rename_all = "snake_case")]
31pub enum ItemState {
32    Open,
33    Sealed,
34}
35
36/// Content an item holds
37#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
38#[serde(tag = "kind", content = "content", rename_all = "snake_case")]
39pub enum ConversationContent {
40    User(Vec<acp::ContentBlock>),
41    Assistant(Vec<acp::ContentBlock>),
42    Thought(Vec<acp::ContentBlock>),
43    Tool(ToolCall),
44    /// Information from the host rather than the agent.
45    Notice(String),
46}
47
48/// An item keeps its `id` for the life of the conversation; `revision` advances whenever it changes.
49#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
50#[serde(rename_all = "camelCase")]
51pub struct ConversationItem {
52    id: ConversationItemId,
53    pub(super) message_id: Option<acp::MessageId>,
54    #[serde(skip)]
55    pub(super) preserve_user_display: bool,
56    revision: Revision,
57    #[serde(skip)]
58    replacement_revision: Revision,
59    pub(super) state: ItemState,
60    #[serde(flatten)]
61    pub(super) content: ConversationContent,
62}
63
64impl ConversationItem {
65    pub fn id(&self) -> ConversationItemId {
66        self.id
67    }
68
69    pub fn message_id(&self) -> Option<&acp::MessageId> {
70        self.message_id.as_ref()
71    }
72
73    pub fn revision(&self) -> Revision {
74        self.revision
75    }
76
77    pub fn replacement_revision(&self) -> Revision {
78        self.replacement_revision
79    }
80
81    pub fn state(&self) -> ItemState {
82        self.state
83    }
84
85    pub fn content(&self) -> &ConversationContent {
86        &self.content
87    }
88
89    pub fn text(&self) -> Option<Cow<'_, str>> {
90        match &self.content {
91            ConversationContent::User(blocks) => Some(match blocks.as_slice() {
92                [] | [acp::ContentBlock::Text(_)] => plain_text(blocks),
93                blocks => Cow::Owned(map_content_blocks_to_text(&display_content_blocks(blocks))),
94            }),
95            ConversationContent::Assistant(blocks) | ConversationContent::Thought(blocks) => Some(plain_text(blocks)),
96            ConversationContent::Notice(text) => Some(Cow::Borrowed(text)),
97            ConversationContent::Tool(_) => None,
98        }
99    }
100
101    pub fn is_open(&self) -> bool {
102        self.state == ItemState::Open
103    }
104
105    pub(super) fn new(
106        id: ConversationItemId,
107        revision: Revision,
108        state: ItemState,
109        content: ConversationContent,
110    ) -> Self {
111        Self {
112            id,
113            message_id: None,
114            preserve_user_display: false,
115            revision,
116            replacement_revision: revision,
117            state,
118            content,
119        }
120    }
121
122    pub(super) fn touch(&mut self, revision: Revision, rewrites: bool) {
123        if rewrites {
124            self.replacement_revision = revision;
125        }
126        self.revision = revision;
127    }
128}
129
130impl ConversationId {
131    pub(super) fn next() -> Self {
132        static NEXT: AtomicU64 = AtomicU64::new(1);
133        Self(NEXT.fetch_add(1, Ordering::Relaxed))
134    }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub(super) enum MessageRole {
139    User,
140    Assistant,
141    Thought,
142}
143
144impl MessageRole {
145    pub(super) fn content(self, blocks: Vec<acp::ContentBlock>) -> ConversationContent {
146        match self {
147            Self::User => ConversationContent::User(blocks),
148            Self::Assistant => ConversationContent::Assistant(blocks),
149            Self::Thought => ConversationContent::Thought(blocks),
150        }
151    }
152
153    pub(super) fn blocks_mut(self, content: &mut ConversationContent) -> Option<&mut Vec<acp::ContentBlock>> {
154        match (self, content) {
155            (Self::User, ConversationContent::User(blocks))
156            | (Self::Assistant, ConversationContent::Assistant(blocks))
157            | (Self::Thought, ConversationContent::Thought(blocks)) => Some(blocks),
158            _ => None,
159        }
160    }
161}
162
163pub(super) fn append_block(blocks: &mut Vec<acp::ContentBlock>, block: &acp::ContentBlock) -> bool {
164    match (blocks.last_mut(), block) {
165        (_, acp::ContentBlock::Text(text)) if text.text.is_empty() => false,
166        (Some(acp::ContentBlock::Text(last)), acp::ContentBlock::Text(text)) => {
167            last.text.push_str(&text.text);
168            true
169        }
170        _ => {
171            blocks.push(block.clone());
172            true
173        }
174    }
175}
176
177impl Revision {
178    pub(super) fn advance(&mut self) -> Self {
179        self.0 = self.0.saturating_add(1);
180        *self
181    }
182}
183
184fn plain_text(blocks: &[acp::ContentBlock]) -> Cow<'_, str> {
185    match blocks {
186        [] => Cow::Borrowed(""),
187        [acp::ContentBlock::Text(text)] => Cow::Borrowed(&text.text),
188        blocks => Cow::Owned(map_content_blocks_to_text(blocks)),
189    }
190}