1use crate::storage::models::{Message, MessageRole};
7
8pub fn system_prompt() -> &'static str {
14 "You are summarizing an AI-assisted coding session. \
15 Produce a one-sentence overview of what the session accomplished, \
16 followed by 2-5 bullet points covering the key technical work.\n\n\
17 Rules:\n\
18 - Keep the total summary under 300 words.\n\
19 - Focus on what was done and why, not how.\n\
20 - Do not mention tool calls or internal mechanics.\n\
21 - Use plain text only. No markdown headers, no special formatting.\n\
22 - Start bullet points with a dash (-)."
23}
24
25pub fn prepare_conversation(messages: &[Message], max_chars: usize) -> String {
38 if messages.is_empty() {
39 return String::new();
40 }
41
42 let formatted = format_messages(messages);
43
44 if max_chars == 0 || formatted.len() <= max_chars {
45 return formatted;
46 }
47
48 truncate_conversation(messages, max_chars)
50}
51
52fn format_messages(messages: &[Message]) -> String {
54 let mut parts = Vec::with_capacity(messages.len());
55
56 for msg in messages {
57 let text = msg.content.text();
58 let formatted = format_single_message(msg, &text);
59 if !formatted.is_empty() {
60 parts.push(formatted);
61 }
62 }
63
64 parts.join("\n\n")
65}
66
67fn format_single_message(msg: &Message, text: &str) -> String {
73 if text.is_empty() {
74 return String::new();
75 }
76
77 let header = match msg.role {
78 MessageRole::User => {
79 let ts = msg.timestamp.format("%Y-%m-%d %H:%M UTC");
80 format!("[User] ({ts})")
81 }
82 MessageRole::Assistant => "[Assistant]".to_string(),
83 MessageRole::System => {
84 let ts = msg.timestamp.format("%Y-%m-%d %H:%M UTC");
85 format!("[System] ({ts})")
86 }
87 };
88
89 format!("{header}\n{text}")
90}
91
92fn truncate_conversation(messages: &[Message], max_chars: usize) -> String {
97 let count = messages.len();
98
99 let head_count = ((count as f64) * 0.2).ceil() as usize;
101 let tail_count = ((count as f64) * 0.3).ceil() as usize;
102
103 let (head_count, tail_count) = if head_count + tail_count >= count {
105 return format_messages(messages);
107 } else {
108 (head_count, tail_count)
109 };
110
111 let omitted = count - head_count - tail_count;
112 let head_msgs = &messages[..head_count];
113 let tail_msgs = &messages[count - tail_count..];
114
115 let head_text = format_messages(head_msgs);
116 let marker = format!("[... {omitted} messages omitted ...]");
117 let tail_text = format_messages(tail_msgs);
118
119 let result = format!("{head_text}\n\n{marker}\n\n{tail_text}");
120
121 if result.len() > max_chars {
123 let truncated: String = result.chars().take(max_chars).collect();
124 truncated
125 } else {
126 result
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::storage::models::{ContentBlock, MessageContent};
134 use chrono::{TimeZone, Utc};
135 use uuid::Uuid;
136
137 fn make_message(role: MessageRole, text: &str, index: i32) -> Message {
139 Message {
140 id: Uuid::new_v4(),
141 session_id: Uuid::new_v4(),
142 parent_id: None,
143 index,
144 timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap(),
145 role,
146 content: MessageContent::Text(text.to_string()),
147 model: None,
148 git_branch: None,
149 cwd: None,
150 }
151 }
152
153 fn make_tool_only_message(index: i32) -> Message {
155 Message {
156 id: Uuid::new_v4(),
157 session_id: Uuid::new_v4(),
158 parent_id: None,
159 index,
160 timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap(),
161 role: MessageRole::Assistant,
162 content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
163 id: "tool_1".to_string(),
164 name: "Read".to_string(),
165 input: serde_json::json!({"file_path": "/tmp/test.rs"}),
166 }]),
167 model: None,
168 git_branch: None,
169 cwd: None,
170 }
171 }
172
173 #[test]
174 fn test_system_prompt_is_non_empty() {
175 let prompt = system_prompt();
176 assert!(!prompt.is_empty());
177 assert!(prompt.contains("summariz"));
178 assert!(prompt.contains("300 words"));
179 }
180
181 #[test]
182 fn test_basic_user_and_assistant_formatting() {
183 let messages = vec![
184 make_message(MessageRole::User, "Fix the login bug", 0),
185 make_message(MessageRole::Assistant, "I found the issue in auth.rs", 1),
186 ];
187
188 let result = prepare_conversation(&messages, 10_000);
189
190 assert!(result.contains("[User] (2024-01-15 14:30 UTC)"));
191 assert!(result.contains("Fix the login bug"));
192 assert!(result.contains("[Assistant]"));
193 assert!(result.contains("I found the issue in auth.rs"));
194 assert!(!result.contains("[Assistant] ("));
196 }
197
198 #[test]
199 fn test_system_message_formatting() {
200 let messages = vec![make_message(
201 MessageRole::System,
202 "You are a coding assistant",
203 0,
204 )];
205
206 let result = prepare_conversation(&messages, 10_000);
207
208 assert!(result.contains("[System] (2024-01-15 14:30 UTC)"));
209 assert!(result.contains("You are a coding assistant"));
210 }
211
212 #[test]
213 fn test_empty_messages_returns_empty_string() {
214 let messages: Vec<Message> = vec![];
215 let result = prepare_conversation(&messages, 10_000);
216 assert!(result.is_empty());
217 }
218
219 #[test]
220 fn test_single_message_formatting() {
221 let messages = vec![make_message(MessageRole::User, "Hello", 0)];
222
223 let result = prepare_conversation(&messages, 10_000);
224
225 assert_eq!(result, "[User] (2024-01-15 14:30 UTC)\nHello");
226 }
227
228 #[test]
229 fn test_tool_only_messages_handled_gracefully() {
230 let messages = vec![
231 make_message(MessageRole::User, "Read that file", 0),
232 make_tool_only_message(1),
233 make_message(MessageRole::Assistant, "Done reading", 2),
234 ];
235
236 let result = prepare_conversation(&messages, 10_000);
237
238 assert!(result.contains("Read that file"));
240 assert!(result.contains("Done reading"));
241 assert!(!result.contains("\n\n\n\n"));
243 }
244
245 #[test]
246 fn test_truncation_with_head_and_tail_strategy() {
247 let mut messages = Vec::new();
250 for i in 0..20 {
251 let role = if i % 2 == 0 {
252 MessageRole::User
253 } else {
254 MessageRole::Assistant
255 };
256 messages.push(make_message(role, &format!("Message number {i}"), i));
257 }
258
259 let result = prepare_conversation(&messages, 500);
262
263 assert!(result.contains("Message number 0"));
264 assert!(result.contains("[... 10 messages omitted ...]"));
265 assert!(result.contains("Message number 19"));
266 }
267
268 #[test]
269 fn test_no_truncation_when_under_limit() {
270 let messages = vec![
271 make_message(MessageRole::User, "Short", 0),
272 make_message(MessageRole::Assistant, "Reply", 1),
273 ];
274
275 let result = prepare_conversation(&messages, 10_000);
276
277 assert!(!result.contains("omitted"));
278 }
279
280 #[test]
281 fn test_truncation_preserves_first_and_last_messages() {
282 let mut messages = Vec::new();
284 for i in 0..10 {
285 messages.push(make_message(MessageRole::User, &format!("msg-{i}"), i));
286 }
287
288 let result = prepare_conversation(&messages, 250);
291
292 assert!(result.contains("msg-0"));
294 assert!(result.contains("msg-1"));
295 assert!(result.contains("msg-7"));
297 assert!(result.contains("msg-8"));
298 assert!(result.contains("msg-9"));
299 assert!(result.contains("[... 5 messages omitted ...]"));
301 }
302
303 #[test]
304 fn test_all_tool_only_messages_produce_empty_output() {
305 let messages = vec![make_tool_only_message(0), make_tool_only_message(1)];
306
307 let result = prepare_conversation(&messages, 10_000);
308
309 assert!(result.is_empty());
310 }
311
312 #[test]
313 fn test_max_chars_zero_returns_full_conversation() {
314 let messages = vec![
315 make_message(MessageRole::User, "Hello", 0),
316 make_message(MessageRole::Assistant, "World", 1),
317 ];
318
319 let result = prepare_conversation(&messages, 0);
320
321 assert!(result.contains("Hello"));
322 assert!(result.contains("World"));
323 }
324
325 #[test]
326 fn test_few_messages_no_truncation_when_head_tail_covers_all() {
327 let two_messages = vec![
330 make_message(MessageRole::User, "Alpha", 0),
331 make_message(MessageRole::Assistant, "Beta", 1),
332 ];
333
334 let full = prepare_conversation(&two_messages, 10_000);
337 let truncated = prepare_conversation(&two_messages, 1);
338
339 assert!(!full.contains("omitted"));
342 assert!(truncated.len() <= full.len());
345 }
346
347 #[test]
348 fn test_small_message_set_truncation() {
349 let messages = vec![
353 make_message(
354 MessageRole::User,
355 "The very first user message in the session",
356 0,
357 ),
358 make_message(
359 MessageRole::Assistant,
360 "A long middle response that should be omitted from output",
361 1,
362 ),
363 make_message(
364 MessageRole::User,
365 "Another middle message that should be omitted from output",
366 2,
367 ),
368 make_message(
369 MessageRole::Assistant,
370 "The penultimate message in the tail section",
371 3,
372 ),
373 make_message(
374 MessageRole::User,
375 "The final message in the conversation",
376 4,
377 ),
378 ];
379
380 let full = prepare_conversation(&messages, 10_000);
381 let result = prepare_conversation(&messages, full.len() - 1);
384
385 assert!(result.contains("The very first user message"));
386 assert!(result.contains("penultimate message"));
387 assert!(result.contains("final message"));
388 assert!(result.contains("[... 2 messages omitted ...]"));
389 assert!(!result.contains("A long middle response"));
391 assert!(!result.contains("Another middle message"));
392 }
393}