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    revision: Revision,
55    #[serde(skip)]
56    replacement_revision: Revision,
57    pub(super) state: ItemState,
58    #[serde(flatten)]
59    pub(super) content: ConversationContent,
60}
61
62impl ConversationItem {
63    pub fn id(&self) -> ConversationItemId {
64        self.id
65    }
66
67    pub fn message_id(&self) -> Option<&acp::MessageId> {
68        self.message_id.as_ref()
69    }
70
71    pub fn revision(&self) -> Revision {
72        self.revision
73    }
74
75    pub fn replacement_revision(&self) -> Revision {
76        self.replacement_revision
77    }
78
79    pub fn state(&self) -> ItemState {
80        self.state
81    }
82
83    pub fn content(&self) -> &ConversationContent {
84        &self.content
85    }
86
87    pub fn text(&self) -> Option<Cow<'_, str>> {
88        match &self.content {
89            ConversationContent::User(blocks) => Some(match blocks.as_slice() {
90                [] | [acp::ContentBlock::Text(_)] => plain_text(blocks),
91                blocks => Cow::Owned(map_content_blocks_to_text(&display_content_blocks(blocks))),
92            }),
93            ConversationContent::Assistant(blocks) | ConversationContent::Thought(blocks) => Some(plain_text(blocks)),
94            ConversationContent::Notice(text) => Some(Cow::Borrowed(text)),
95            ConversationContent::Tool(_) => None,
96        }
97    }
98
99    pub fn is_open(&self) -> bool {
100        self.state == ItemState::Open
101    }
102
103    pub(super) fn new(
104        id: ConversationItemId,
105        revision: Revision,
106        state: ItemState,
107        content: ConversationContent,
108    ) -> Self {
109        Self { id, message_id: None, revision, replacement_revision: revision, state, content }
110    }
111
112    pub(super) fn touch(&mut self, revision: Revision, rewrites: bool) {
113        if rewrites {
114            self.replacement_revision = revision;
115        }
116        self.revision = revision;
117    }
118}
119
120impl ConversationId {
121    pub(super) fn next() -> Self {
122        static NEXT: AtomicU64 = AtomicU64::new(1);
123        Self(NEXT.fetch_add(1, Ordering::Relaxed))
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub(super) enum MessageRole {
129    User,
130    Assistant,
131    Thought,
132}
133
134impl MessageRole {
135    pub(super) fn content(self, blocks: Vec<acp::ContentBlock>) -> ConversationContent {
136        match self {
137            Self::User => ConversationContent::User(blocks),
138            Self::Assistant => ConversationContent::Assistant(blocks),
139            Self::Thought => ConversationContent::Thought(blocks),
140        }
141    }
142
143    pub(super) fn blocks_mut(self, content: &mut ConversationContent) -> Option<&mut Vec<acp::ContentBlock>> {
144        match (self, content) {
145            (Self::User, ConversationContent::User(blocks))
146            | (Self::Assistant, ConversationContent::Assistant(blocks))
147            | (Self::Thought, ConversationContent::Thought(blocks)) => Some(blocks),
148            _ => None,
149        }
150    }
151}
152
153pub(super) fn append_block(blocks: &mut Vec<acp::ContentBlock>, block: &acp::ContentBlock) -> bool {
154    match (blocks.last_mut(), block) {
155        (_, acp::ContentBlock::Text(text)) if text.text.is_empty() => false,
156        (Some(acp::ContentBlock::Text(last)), acp::ContentBlock::Text(text)) => {
157            last.text.push_str(&text.text);
158            true
159        }
160        _ => {
161            blocks.push(block.clone());
162            true
163        }
164    }
165}
166
167impl Revision {
168    pub(super) fn advance(&mut self) -> Self {
169        self.0 = self.0.saturating_add(1);
170        *self
171    }
172}
173
174fn plain_text(blocks: &[acp::ContentBlock]) -> Cow<'_, str> {
175    match blocks {
176        [] => Cow::Borrowed(""),
177        [acp::ContentBlock::Text(text)] => Cow::Borrowed(&text.text),
178        blocks => Cow::Owned(map_content_blocks_to_text(blocks)),
179    }
180}