perspt_sdk/model/
conversation.rs1use serde::{Deserialize, Serialize};
10
11use super::tool::ProviderToolCall;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case", tag = "role")]
16pub enum Message {
17 System {
18 content: String,
19 },
20 User {
21 content: String,
22 },
23 Assistant {
24 content: String,
25 },
26 AssistantToolCalls {
28 calls: Vec<ProviderToolCall>,
29 },
30 ToolResponse {
32 call_id: String,
33 content: String,
34 },
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40pub struct Conversation {
41 messages: Vec<Message>,
42}
43
44impl Conversation {
45 pub fn with_system(prompt: impl Into<String>) -> Self {
47 Self {
48 messages: vec![Message::System {
49 content: prompt.into(),
50 }],
51 }
52 }
53
54 pub fn push(&mut self, message: Message) {
56 self.messages.push(message);
57 }
58
59 pub fn push_user(&mut self, content: impl Into<String>) {
61 self.push(Message::User {
62 content: content.into(),
63 });
64 }
65
66 pub fn push_tool_calls(&mut self, calls: Vec<ProviderToolCall>) {
68 self.push(Message::AssistantToolCalls { calls });
69 }
70
71 pub fn push_tool_response(&mut self, call_id: impl Into<String>, content: impl Into<String>) {
73 self.push(Message::ToolResponse {
74 call_id: call_id.into(),
75 content: content.into(),
76 });
77 }
78
79 pub fn messages(&self) -> &[Message] {
81 &self.messages
82 }
83
84 pub fn estimated_chars(&self) -> usize {
88 self.messages
89 .iter()
90 .map(|message| serde_json::to_string(message).map_or(0, |value| value.len()))
91 .sum()
92 }
93
94 pub fn compact_with_control(&mut self, control: impl Into<String>) {
98 let unresolved = self.unresolved_call_ids();
99 let mut projected = Vec::new();
100 if let Some(system) = self
101 .messages
102 .iter()
103 .find(|message| matches!(message, Message::System { .. }))
104 {
105 projected.push(system.clone());
106 }
107 projected.push(Message::System {
108 content: control.into(),
109 });
110 if let Some(user) = self
111 .messages
112 .iter()
113 .rev()
114 .find(|message| matches!(message, Message::User { .. }))
115 {
116 projected.push(user.clone());
117 }
118 for message in &self.messages {
119 if let Message::AssistantToolCalls { calls } = message {
120 let calls: Vec<_> = calls
121 .iter()
122 .filter(|call| unresolved.contains(&call.call_id))
123 .cloned()
124 .collect();
125 if !calls.is_empty() {
126 projected.push(Message::AssistantToolCalls { calls });
127 }
128 }
129 }
130 self.messages = projected;
131 }
132
133 pub fn unresolved_call_ids(&self) -> Vec<String> {
136 let mut open: Vec<String> = Vec::new();
137 for message in &self.messages {
138 match message {
139 Message::AssistantToolCalls { calls } => {
140 open.extend(calls.iter().map(|c| c.call_id.clone()));
141 }
142 Message::ToolResponse { call_id, .. } => {
143 open.retain(|id| id != call_id);
144 }
145 _ => {}
146 }
147 }
148 open
149 }
150
151 pub fn len(&self) -> usize {
153 self.messages.len()
154 }
155
156 pub fn is_empty(&self) -> bool {
158 self.messages.is_empty()
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn call(id: &str) -> ProviderToolCall {
167 ProviderToolCall {
168 call_id: id.into(),
169 name: "read_file".into(),
170 arguments: serde_json::json!({}),
171 }
172 }
173
174 #[test]
175 fn tracks_unresolved_tool_calls() {
176 let mut conversation = Conversation::with_system("harness");
177 conversation.push_tool_calls(vec![call("a"), call("b")]);
178 conversation.push_tool_response("a", "contents");
179 assert_eq!(conversation.unresolved_call_ids(), ["b"]);
180 conversation.push_tool_response("b", "denied");
181 assert!(conversation.unresolved_call_ids().is_empty());
182 }
183
184 #[test]
185 fn conversation_is_vendor_neutral_serializable() {
186 let mut conversation = Conversation::with_system("s");
187 conversation.push_user("fix the test");
188 let json = serde_json::to_string(&conversation).unwrap();
189 let back: Conversation = serde_json::from_str(&json).unwrap();
190 assert_eq!(back, conversation);
191 }
192
193 #[test]
194 fn compaction_drops_closed_history_but_preserves_open_calls() {
195 let mut conversation = Conversation::with_system("harness");
196 conversation.push_user("large goal");
197 conversation.push_tool_calls(vec![call("closed"), call("open")]);
198 conversation.push_tool_response("closed", "contents");
199 conversation.compact_with_control("CONTROL");
200
201 assert_eq!(conversation.unresolved_call_ids(), ["open"]);
202 assert_eq!(conversation.len(), 4);
203 assert!(matches!(
204 &conversation.messages()[3],
205 Message::AssistantToolCalls { calls } if calls == &vec![call("open")]
206 ));
207 }
208}