Skip to main content

hanzo_protocol/
items.rs

1use crate::models::MessagePhase;
2use crate::models::WebSearchAction;
3use crate::protocol::AgentMessageEvent;
4use crate::protocol::AgentReasoningEvent;
5use crate::protocol::AgentReasoningRawContentEvent;
6use crate::protocol::ContextCompactedEvent;
7use crate::protocol::EventMsg;
8use crate::protocol::UserMessageEvent;
9use crate::protocol::WebSearchEndEvent;
10use crate::user_input::ByteRange;
11use crate::user_input::TextElement;
12use crate::user_input::UserInput;
13use schemars::JsonSchema;
14use serde::Deserialize;
15use serde::Serialize;
16use ts_rs::TS;
17
18#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
19#[serde(tag = "type")]
20#[ts(tag = "type")]
21pub enum TurnItem {
22    UserMessage(UserMessageItem),
23    AgentMessage(AgentMessageItem),
24    Plan(PlanItem),
25    Reasoning(ReasoningItem),
26    WebSearch(WebSearchItem),
27    ContextCompaction(ContextCompactionItem),
28}
29
30#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
31pub struct UserMessageItem {
32    pub id: String,
33    pub content: Vec<UserInput>,
34}
35
36#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
37#[serde(tag = "type")]
38#[ts(tag = "type")]
39pub enum AgentMessageContent {
40    Text { text: String },
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
44/// Assistant-authored message payload used in turn-item streams.
45///
46/// `phase` is optional because not all providers/models emit it. Consumers
47/// should use it when present, but retain legacy completion semantics when it
48/// is `None`.
49pub struct AgentMessageItem {
50    pub id: String,
51    pub content: Vec<AgentMessageContent>,
52    /// Optional phase metadata carried through from `ResponseItem::Message`.
53    ///
54    /// This is currently used by TUI rendering to distinguish mid-turn
55    /// commentary from a final answer and avoid status-indicator jitter.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    #[ts(optional)]
58    pub phase: Option<MessagePhase>,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
62pub struct PlanItem {
63    pub id: String,
64    pub text: String,
65}
66
67#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
68pub struct ReasoningItem {
69    pub id: String,
70    pub summary_text: Vec<String>,
71    #[serde(default)]
72    pub raw_content: Vec<String>,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
76pub struct WebSearchItem {
77    pub id: String,
78    pub query: String,
79    pub action: WebSearchAction,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
83pub struct ContextCompactionItem {
84    pub id: String,
85}
86
87impl ContextCompactionItem {
88    pub fn new() -> Self {
89        Self {
90            id: uuid::Uuid::new_v4().to_string(),
91        }
92    }
93
94    pub fn as_legacy_event(&self) -> EventMsg {
95        EventMsg::ContextCompacted(ContextCompactedEvent {})
96    }
97}
98
99impl Default for ContextCompactionItem {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl UserMessageItem {
106    pub fn new(content: &[UserInput]) -> Self {
107        Self {
108            id: uuid::Uuid::new_v4().to_string(),
109            content: content.to_vec(),
110        }
111    }
112
113    pub fn as_legacy_event(&self) -> EventMsg {
114        // Legacy user-message events flatten only text inputs into `message` and
115        // rebase text element ranges onto that concatenated text.
116        EventMsg::UserMessage(UserMessageEvent {
117            message: self.message(),
118            images: Some(self.image_urls()),
119            local_images: self.local_image_paths(),
120            text_elements: self.text_elements(),
121        })
122    }
123
124    pub fn message(&self) -> String {
125        self.content
126            .iter()
127            .map(|c| match c {
128                UserInput::Text { text, .. } => text.clone(),
129                _ => String::new(),
130            })
131            .collect::<Vec<String>>()
132            .join("")
133    }
134
135    pub fn text_elements(&self) -> Vec<TextElement> {
136        let mut out = Vec::new();
137        let mut offset = 0usize;
138        for input in &self.content {
139            if let UserInput::Text {
140                text,
141                text_elements,
142            } = input
143            {
144                // Text element ranges are relative to each text chunk; offset them so they align
145                // with the concatenated message returned by `message()`.
146                for elem in text_elements {
147                    let byte_range = ByteRange {
148                        start: offset + elem.byte_range.start,
149                        end: offset + elem.byte_range.end,
150                    };
151                    out.push(TextElement::new(
152                        byte_range,
153                        elem.placeholder(text).map(str::to_string),
154                    ));
155                }
156                offset += text.len();
157            }
158        }
159        out
160    }
161
162    pub fn image_urls(&self) -> Vec<String> {
163        self.content
164            .iter()
165            .filter_map(|c| match c {
166                UserInput::Image { image_url } => Some(image_url.clone()),
167                _ => None,
168            })
169            .collect()
170    }
171
172    pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
173        self.content
174            .iter()
175            .filter_map(|c| match c {
176                UserInput::LocalImage { path } => Some(path.clone()),
177                _ => None,
178            })
179            .collect()
180    }
181}
182
183impl AgentMessageItem {
184    pub fn new(content: &[AgentMessageContent]) -> Self {
185        Self {
186            id: uuid::Uuid::new_v4().to_string(),
187            content: content.to_vec(),
188            phase: None,
189        }
190    }
191
192    pub fn as_legacy_events(&self) -> Vec<EventMsg> {
193        // Legacy events only preserve visible assistant text; `phase` has no
194        // representation in the v1 event stream.
195        self.content
196            .iter()
197            .map(|c| match c {
198                AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent {
199                    message: text.clone(),
200                }),
201            })
202            .collect()
203    }
204}
205
206impl ReasoningItem {
207    pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
208        let mut events = Vec::new();
209        for summary in &self.summary_text {
210            events.push(EventMsg::AgentReasoning(AgentReasoningEvent {
211                text: summary.clone(),
212            }));
213        }
214
215        if show_raw_agent_reasoning {
216            for entry in &self.raw_content {
217                events.push(EventMsg::AgentReasoningRawContent(
218                    AgentReasoningRawContentEvent {
219                        text: entry.clone(),
220                    },
221                ));
222            }
223        }
224
225        events
226    }
227}
228
229impl WebSearchItem {
230    pub fn as_legacy_event(&self) -> EventMsg {
231        EventMsg::WebSearchEnd(WebSearchEndEvent {
232            call_id: self.id.clone(),
233            query: self.query.clone(),
234            action: self.action.clone(),
235        })
236    }
237}
238
239impl TurnItem {
240    pub fn id(&self) -> String {
241        match self {
242            TurnItem::UserMessage(item) => item.id.clone(),
243            TurnItem::AgentMessage(item) => item.id.clone(),
244            TurnItem::Plan(item) => item.id.clone(),
245            TurnItem::Reasoning(item) => item.id.clone(),
246            TurnItem::WebSearch(item) => item.id.clone(),
247            TurnItem::ContextCompaction(item) => item.id.clone(),
248        }
249    }
250
251    pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
252        match self {
253            TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
254            TurnItem::AgentMessage(item) => item.as_legacy_events(),
255            TurnItem::Plan(_) => Vec::new(),
256            TurnItem::WebSearch(item) => vec![item.as_legacy_event()],
257            TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning),
258            TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()],
259        }
260    }
261}
262