Skip to main content

vtcode_core/utils/session_archive/
message.rs

1use serde::{Deserialize, Deserializer, Serialize};
2
3use crate::llm::provider::{AssistantPhase, Message, MessageClearAt, MessageContent, MessageRole, ToolCall};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6pub struct SessionMessage {
7    pub role: MessageRole,
8    pub content: MessageContent,
9    // Keep sparse metadata boxed so long histories don't pay large inline Option<T> costs.
10    #[serde(
11        default,
12        skip_serializing_if = "Option::is_none",
13        deserialize_with = "deserialize_boxed_non_empty_string_opt"
14    )]
15    pub reasoning: Option<Box<String>>,
16    #[serde(
17        default,
18        skip_serializing_if = "Option::is_none",
19        deserialize_with = "deserialize_boxed_non_empty_vec_opt"
20    )]
21    pub reasoning_details: Option<Box<Vec<serde_json::Value>>>,
22    #[serde(
23        default,
24        skip_serializing_if = "Option::is_none",
25        deserialize_with = "deserialize_boxed_non_empty_vec_opt"
26    )]
27    pub tool_calls: Option<Box<Vec<ToolCall>>>,
28    #[serde(default, deserialize_with = "deserialize_boxed_non_empty_string_opt")]
29    pub tool_call_id: Option<Box<String>>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub phase: Option<AssistantPhase>,
32    #[serde(
33        default,
34        skip_serializing_if = "Option::is_none",
35        deserialize_with = "deserialize_boxed_non_empty_string_opt"
36    )]
37    pub origin_tool: Option<Box<String>>,
38    #[serde(
39        default,
40        skip_serializing_if = "Option::is_none",
41        deserialize_with = "deserialize_boxed_metadata_opt"
42    )]
43    pub metadata: Option<Box<crate::core::message_metadata::MessageMetadata>>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub clear_at: Option<MessageClearAt>,
46}
47
48impl Eq for SessionMessage {}
49
50#[expect(
51    clippy::box_collection,
52    reason = "Intentional compatibility, platform, test, or API-shape suppression."
53)]
54#[inline]
55fn boxed_non_empty_string(value: Option<String>) -> Option<Box<String>> {
56    value.and_then(|value| (!value.is_empty()).then_some(Box::new(value)))
57}
58
59#[expect(
60    clippy::box_collection,
61    reason = "Intentional compatibility, platform, test, or API-shape suppression."
62)]
63#[inline]
64fn boxed_non_empty_vec<T>(value: Option<Vec<T>>) -> Option<Box<Vec<T>>> {
65    value.and_then(|value| (!value.is_empty()).then_some(Box::new(value)))
66}
67
68#[expect(
69    clippy::box_collection,
70    reason = "Intentional compatibility, platform, test, or API-shape suppression."
71)]
72fn clone_non_empty_boxed_string(value: &Option<Box<String>>) -> Option<String> {
73    value
74        .as_deref()
75        .and_then(|value| (!value.is_empty()).then_some(value.to_owned()))
76}
77
78fn deserialize_boxed_metadata_opt<'de, D>(
79    deserializer: D,
80) -> Result<Option<Box<crate::core::message_metadata::MessageMetadata>>, D::Error>
81where
82    D: Deserializer<'de>,
83{
84    crate::core::message_metadata::MessageMetadata::deserialize(deserializer).map(|m| Some(Box::new(m)))
85}
86
87#[expect(
88    clippy::box_collection,
89    reason = "Intentional compatibility, platform, test, or API-shape suppression."
90)]
91fn clone_non_empty_boxed_vec<T: Clone>(value: &Option<Box<Vec<T>>>) -> Option<Vec<T>> {
92    value.as_deref().and_then(|value| (!value.is_empty()).then_some(value.clone()))
93}
94
95#[expect(
96    clippy::box_collection,
97    reason = "Intentional compatibility, platform, test, or API-shape suppression."
98)]
99fn deserialize_boxed_non_empty_string_opt<'de, D>(deserializer: D) -> Result<Option<Box<String>>, D::Error>
100where
101    D: Deserializer<'de>,
102{
103    Option::<String>::deserialize(deserializer).map(boxed_non_empty_string)
104}
105
106#[expect(
107    clippy::box_collection,
108    reason = "Intentional compatibility, platform, test, or API-shape suppression."
109)]
110fn deserialize_boxed_non_empty_vec_opt<'de, D, T>(deserializer: D) -> Result<Option<Box<Vec<T>>>, D::Error>
111where
112    D: Deserializer<'de>,
113    T: Deserialize<'de>,
114{
115    Option::<Vec<T>>::deserialize(deserializer).map(boxed_non_empty_vec)
116}
117
118impl SessionMessage {
119    fn base(role: MessageRole, content: MessageContent) -> Self {
120        Self {
121            role,
122            content,
123            reasoning: None,
124            reasoning_details: None,
125            tool_calls: None,
126            tool_call_id: None,
127            phase: None,
128            origin_tool: None,
129            metadata: None,
130            clear_at: None,
131        }
132    }
133
134    pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
135        Self::base(role, MessageContent::Text(content.into()))
136    }
137
138    pub fn with_content(role: MessageRole, content: MessageContent) -> Self {
139        Self::base(role, content)
140    }
141
142    pub fn with_tool_call_id(role: MessageRole, content: impl Into<String>, tool_call_id: Option<String>) -> Self {
143        Self::with_tool_call_id_content(role, MessageContent::Text(content.into()), tool_call_id)
144    }
145
146    pub fn with_tool_call_id_content(role: MessageRole, content: MessageContent, tool_call_id: Option<String>) -> Self {
147        let mut message = Self::base(role, content);
148        message.tool_call_id = boxed_non_empty_string(tool_call_id);
149        message
150    }
151}
152
153impl From<&Message> for SessionMessage {
154    fn from(message: &Message) -> Self {
155        Self {
156            role: message.role,
157            content: message.content.clone(),
158            reasoning: boxed_non_empty_string(message.reasoning.clone()),
159            reasoning_details: boxed_non_empty_vec(message.reasoning_details.clone()),
160            tool_calls: boxed_non_empty_vec(message.tool_calls.clone()),
161            tool_call_id: boxed_non_empty_string(message.tool_call_id.clone()),
162            phase: message.phase,
163            origin_tool: boxed_non_empty_string(message.origin_tool.clone()),
164            metadata: message.metadata.clone().map(Box::new),
165            clear_at: message.clear_at,
166        }
167    }
168}
169
170impl From<&SessionMessage> for Message {
171    fn from(message: &SessionMessage) -> Self {
172        Self {
173            role: message.role,
174            content: message.content.clone(),
175            reasoning: clone_non_empty_boxed_string(&message.reasoning),
176            reasoning_details: clone_non_empty_boxed_vec(&message.reasoning_details),
177            tool_calls: clone_non_empty_boxed_vec(&message.tool_calls),
178            tool_call_id: clone_non_empty_boxed_string(&message.tool_call_id),
179            phase: message.phase,
180            origin_tool: clone_non_empty_boxed_string(&message.origin_tool),
181            metadata: message.metadata.as_deref().cloned(),
182            clear_at: message.clear_at,
183        }
184    }
185}