1use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Message {
27 pub role: String,
28 pub content: String,
29}
30
31impl Message {
32 pub fn user(content: &str) -> Self {
34 Message {
35 role: "user".to_string(),
36 content: content.to_string(),
37 }
38 }
39
40 pub fn assistant(content: &str) -> Self {
42 Message {
43 role: "assistant".to_string(),
44 content: content.to_string(),
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
51pub struct ConversationHistory {
52 messages: Vec<Message>,
53}
54
55impl ConversationHistory {
56 pub fn new() -> Self {
58 ConversationHistory {
59 messages: Vec::new(),
60 }
61 }
62
63 pub fn add_user(&mut self, content: &str) {
65 self.messages.push(Message::user(content));
66 }
67
68 pub fn add_assistant(&mut self, content: &str) {
70 self.messages.push(Message::assistant(content));
71 }
72
73 pub fn messages(&self) -> &[Message] {
75 &self.messages
76 }
77
78 pub fn len(&self) -> usize {
80 self.messages.len()
81 }
82
83 pub fn is_empty(&self) -> bool {
85 self.messages.is_empty()
86 }
87
88 pub fn turn_count(&self) -> usize {
90 self.messages.len() / 2
91 }
92
93 pub fn total_chars(&self) -> usize {
95 self.messages.iter().map(|m| m.content.len()).sum()
96 }
97
98 pub fn clear(&mut self) {
100 self.messages.clear();
101 }
102
103 pub fn truncate_to_budget(&mut self, max_chars: usize) -> usize {
112 if max_chars == 0 || self.total_chars() <= max_chars {
113 return 0;
114 }
115
116 let mut dropped = 0;
117
118 while self.messages.len() > 2 && self.total_chars() > max_chars {
121 self.messages.remove(0);
123 self.messages.remove(0);
124 dropped += 2;
125 }
126
127 dropped
128 }
129
130 pub fn overflow_count(&self, max_chars: usize) -> usize {
133 if max_chars == 0 || self.total_chars() <= max_chars {
134 return 0;
135 }
136
137 let mut chars = self.total_chars();
138 let mut dropped = 0;
139 let mut idx = 0;
140
141 while (self.messages.len() - dropped) > 2 && chars > max_chars {
142 chars -= self.messages[idx].content.len();
143 chars -= self.messages[idx + 1].content.len();
144 dropped += 2;
145 idx += 2;
146 }
147
148 dropped
149 }
150}
151
152#[derive(Debug, Clone)]
160pub struct ContextWindow {
161 pub max_chars: usize,
164 pub total_dropped: usize,
166 pub truncation_count: usize,
168}
169
170const DEFAULT_CONTEXT_BUDGET: usize = 100_000;
172
173impl ContextWindow {
174 pub fn new() -> Self {
176 ContextWindow {
177 max_chars: DEFAULT_CONTEXT_BUDGET,
178 total_dropped: 0,
179 truncation_count: 0,
180 }
181 }
182
183 pub fn with_budget(max_chars: usize) -> Self {
185 ContextWindow {
186 max_chars,
187 total_dropped: 0,
188 truncation_count: 0,
189 }
190 }
191
192 pub fn unlimited() -> Self {
194 ContextWindow {
195 max_chars: 0,
196 total_dropped: 0,
197 truncation_count: 0,
198 }
199 }
200
201 pub fn enforce(&mut self, history: &mut ConversationHistory) -> usize {
204 let dropped = history.truncate_to_budget(self.max_chars);
205 if dropped > 0 {
206 self.total_dropped += dropped;
207 self.truncation_count += 1;
208 }
209 dropped
210 }
211
212 pub fn was_truncated(&self) -> bool {
214 self.total_dropped > 0
215 }
216
217 pub fn estimate_tokens(chars: usize) -> usize {
219 (chars + 3) / 4 }
221}
222
223#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn new_history_is_empty() {
231 let h = ConversationHistory::new();
232 assert!(h.is_empty());
233 assert_eq!(h.len(), 0);
234 assert_eq!(h.turn_count(), 0);
235 assert_eq!(h.total_chars(), 0);
236 }
237
238 #[test]
239 fn add_user_and_assistant() {
240 let mut h = ConversationHistory::new();
241 h.add_user("Hello");
242 h.add_assistant("Hi there");
243 assert_eq!(h.len(), 2);
244 assert_eq!(h.turn_count(), 1);
245 assert!(!h.is_empty());
246 }
247
248 #[test]
249 fn messages_preserve_order() {
250 let mut h = ConversationHistory::new();
251 h.add_user("First");
252 h.add_assistant("Second");
253 h.add_user("Third");
254 h.add_assistant("Fourth");
255
256 let msgs = h.messages();
257 assert_eq!(msgs.len(), 4);
258 assert_eq!(msgs[0].role, "user");
259 assert_eq!(msgs[0].content, "First");
260 assert_eq!(msgs[1].role, "assistant");
261 assert_eq!(msgs[1].content, "Second");
262 assert_eq!(msgs[2].role, "user");
263 assert_eq!(msgs[2].content, "Third");
264 assert_eq!(msgs[3].role, "assistant");
265 assert_eq!(msgs[3].content, "Fourth");
266 }
267
268 #[test]
269 fn total_chars_sums_all() {
270 let mut h = ConversationHistory::new();
271 h.add_user("abc"); h.add_assistant("de"); h.add_user("f"); assert_eq!(h.total_chars(), 6);
275 }
276
277 #[test]
278 fn clear_resets() {
279 let mut h = ConversationHistory::new();
280 h.add_user("Hello");
281 h.add_assistant("Hi");
282 h.clear();
283 assert!(h.is_empty());
284 assert_eq!(h.len(), 0);
285 assert_eq!(h.turn_count(), 0);
286 }
287
288 #[test]
289 fn message_constructors() {
290 let u = Message::user("question");
291 assert_eq!(u.role, "user");
292 assert_eq!(u.content, "question");
293
294 let a = Message::assistant("answer");
295 assert_eq!(a.role, "assistant");
296 assert_eq!(a.content, "answer");
297 }
298
299 #[test]
300 fn turn_count_with_odd_messages() {
301 let mut h = ConversationHistory::new();
302 h.add_user("Hello");
303 assert_eq!(h.turn_count(), 0); h.add_assistant("Hi");
306 assert_eq!(h.turn_count(), 1);
307 h.add_user("Next");
308 assert_eq!(h.turn_count(), 1); }
310
311 #[test]
312 fn multi_turn_accumulation() {
313 let mut h = ConversationHistory::new();
314 for i in 0..5 {
315 h.add_user(&format!("Q{i}"));
316 h.add_assistant(&format!("A{i}"));
317 }
318 assert_eq!(h.len(), 10);
319 assert_eq!(h.turn_count(), 5);
320 let msgs = h.messages();
322 assert_eq!(msgs[8].content, "Q4");
323 assert_eq!(msgs[9].content, "A4");
324 }
325
326 #[test]
329 fn truncate_within_budget_is_noop() {
330 let mut h = ConversationHistory::new();
331 h.add_user("short");
332 h.add_assistant("also short");
333 let dropped = h.truncate_to_budget(1000);
334 assert_eq!(dropped, 0);
335 assert_eq!(h.len(), 2);
336 }
337
338 #[test]
339 fn truncate_drops_oldest_turns() {
340 let mut h = ConversationHistory::new();
341 for i in 0..5 {
343 h.add_user(&format!("Q{i}"));
344 h.add_assistant(&format!("A{i}"));
345 }
346 assert_eq!(h.total_chars(), 20); let dropped = h.truncate_to_budget(8);
350 assert_eq!(dropped, 6);
351 assert_eq!(h.len(), 4);
352 assert_eq!(h.turn_count(), 2);
353
354 let msgs = h.messages();
356 assert_eq!(msgs[0].content, "Q3");
357 assert_eq!(msgs[1].content, "A3");
358 assert_eq!(msgs[2].content, "Q4");
359 assert_eq!(msgs[3].content, "A4");
360 }
361
362 #[test]
363 fn truncate_preserves_minimum_turn() {
364 let mut h = ConversationHistory::new();
365 h.add_user(&"x".repeat(500));
366 h.add_assistant(&"y".repeat(500));
367 let dropped = h.truncate_to_budget(10);
369 assert_eq!(dropped, 0);
370 assert_eq!(h.len(), 2); }
372
373 #[test]
374 fn truncate_unlimited_budget_is_noop() {
375 let mut h = ConversationHistory::new();
376 for i in 0..100 {
377 h.add_user(&format!("Question {i}"));
378 h.add_assistant(&format!("Answer {i}"));
379 }
380 let dropped = h.truncate_to_budget(0); assert_eq!(dropped, 0);
382 assert_eq!(h.len(), 200);
383 }
384
385 #[test]
386 fn overflow_count_without_mutation() {
387 let mut h = ConversationHistory::new();
388 for i in 0..5 {
389 h.add_user(&format!("Q{i}"));
390 h.add_assistant(&format!("A{i}"));
391 }
392 let count = h.overflow_count(8);
393 assert_eq!(count, 6); assert_eq!(h.len(), 10); }
396
397 #[test]
398 fn context_window_default_budget() {
399 let cw = ContextWindow::new();
400 assert_eq!(cw.max_chars, 100_000);
401 assert_eq!(cw.total_dropped, 0);
402 assert_eq!(cw.truncation_count, 0);
403 assert!(!cw.was_truncated());
404 }
405
406 #[test]
407 fn context_window_custom_budget() {
408 let cw = ContextWindow::with_budget(50_000);
409 assert_eq!(cw.max_chars, 50_000);
410 }
411
412 #[test]
413 fn context_window_unlimited() {
414 let cw = ContextWindow::unlimited();
415 assert_eq!(cw.max_chars, 0);
416 }
417
418 #[test]
419 fn context_window_enforce_tracks_stats() {
420 let mut cw = ContextWindow::with_budget(8);
421 let mut h = ConversationHistory::new();
422 for i in 0..5 {
423 h.add_user(&format!("Q{i}"));
424 h.add_assistant(&format!("A{i}"));
425 }
426
427 let dropped = cw.enforce(&mut h);
428 assert_eq!(dropped, 6);
429 assert!(cw.was_truncated());
430 assert_eq!(cw.total_dropped, 6);
431 assert_eq!(cw.truncation_count, 1);
432
433 let dropped2 = cw.enforce(&mut h);
435 assert_eq!(dropped2, 0);
436 assert_eq!(cw.truncation_count, 1); }
438
439 #[test]
440 fn context_window_enforce_multiple_truncations() {
441 let mut cw = ContextWindow::with_budget(20);
442 let mut h = ConversationHistory::new();
443
444 for i in 0..3 {
446 h.add_user(&format!("Q{i}"));
447 h.add_assistant(&format!("A{i}"));
448 }
449 cw.enforce(&mut h); for i in 3..8 {
453 h.add_user(&format!("Q{i}"));
454 h.add_assistant(&format!("A{i}"));
455 }
456 let dropped = cw.enforce(&mut h); assert!(dropped > 0);
458 assert_eq!(cw.truncation_count, 1);
459 assert!(h.total_chars() <= 20);
460 }
461
462 #[test]
463 fn estimate_tokens() {
464 assert_eq!(ContextWindow::estimate_tokens(0), 0);
465 assert_eq!(ContextWindow::estimate_tokens(4), 1);
466 assert_eq!(ContextWindow::estimate_tokens(5), 2);
467 assert_eq!(ContextWindow::estimate_tokens(100), 25);
468 assert_eq!(ContextWindow::estimate_tokens(100_000), 25_000);
469 }
470}