1use crate::agent::{Message, Role};
7use std::collections::HashSet;
8
9#[derive(Debug, Clone)]
11pub struct HandoffConfig {
12 pub max_constraints: usize,
14 pub max_tasks: usize,
16 pub max_recent_messages: usize,
18 pub max_preview_length: usize,
20}
21
22impl Default for HandoffConfig {
23 fn default() -> Self {
24 Self {
25 max_constraints: 10,
26 max_tasks: 15,
27 max_recent_messages: 3,
28 max_preview_length: 200,
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct HandoffContext {
36 pub file_paths: Vec<String>,
38 pub constraints: Vec<String>,
40 pub tasks: Vec<String>,
42 pub recent_messages: Vec<(Role, String)>,
44 pub tags: Vec<String>,
46 pub notes: String,
48}
49
50#[derive(Debug, Clone)]
52pub struct HandoffMetadata {
53 pub model: String,
55 pub message_count: usize,
57 pub tool_calls: usize,
59 pub files_edited: usize,
61}
62
63pub fn generate_handoff_prompt(
83 messages: &[Message],
84 model: &str,
85 tool_calls: usize,
86 files_edited: usize,
87 tags: &[String],
88 notes: &str,
89 config: Option<HandoffConfig>,
90) -> String {
91 let config = config.unwrap_or_default();
92
93 if messages.is_empty() {
94 return "No conversation context available.".to_string();
95 }
96
97 let context = extract_context(messages, &config);
98 let metadata = HandoffMetadata {
99 model: model.to_string(),
100 message_count: messages.len(),
101 tool_calls,
102 files_edited,
103 };
104
105 build_handoff_prompt(&context, &metadata, tags, notes, &config)
106}
107
108fn extract_context(messages: &[Message], config: &HandoffConfig) -> HandoffContext {
110 let mut file_paths: HashSet<String> = HashSet::new();
111 let mut constraints = Vec::new();
112 let mut tasks = Vec::new();
113 let mut seen_messages: HashSet<String> = HashSet::new();
114
115 for msg in messages {
116 let content = &msg.content;
117
118 let content_hash = format!("{:?}:{}", msg.role, content);
120 if seen_messages.contains(&content_hash) {
121 continue;
122 }
123 seen_messages.insert(content_hash);
124
125 extract_file_paths(content, &mut file_paths);
127
128 extract_constraints(content, &mut constraints);
130
131 extract_tasks(content, &mut tasks);
133 }
134
135 let recent_messages: Vec<_> = messages
137 .iter()
138 .rev()
139 .take(config.max_recent_messages)
140 .filter_map(|msg| {
141 let content = &msg.content;
142 if content.is_empty() {
143 return None;
144 }
145 let first_line = content.lines().next().unwrap_or(content);
147 let preview = if first_line.len() > config.max_preview_length {
148 format!("{}...", &first_line[..config.max_preview_length])
149 } else {
150 first_line.to_string()
151 };
152 Some((msg.role.clone(), preview))
153 })
154 .collect::<Vec<_>>()
155 .into_iter()
156 .rev()
157 .collect();
158
159 HandoffContext {
160 file_paths: file_paths.into_iter().collect(),
161 constraints,
162 tasks,
163 recent_messages,
164 tags: Vec::new(),
165 notes: String::new(),
166 }
167}
168fn extract_file_paths(content: &str, file_paths: &mut HashSet<String>) {
170 for line in content.lines() {
171 let extensions = [
173 ".rs", ".ts", ".js", ".py", ".go", ".java", ".md", ".toml", ".json",
174 ];
175
176 for word in line.split_whitespace() {
177 let word = word.trim_matches(['\"', '\'', '(', ')', ',', ':', '[', ']']);
178
179 if extensions.iter().any(|ext| word.ends_with(ext)) {
181 file_paths.insert(word.to_string());
182 continue;
183 }
184
185 if word.contains('/')
187 && (word.contains("src") || word.contains("lib") || word.contains("test"))
188 {
189 file_paths.insert(word.to_string());
190 }
191 }
192 }
193}
194
195fn extract_constraints(content: &str, constraints: &mut Vec<String>) {
197 for line in content.lines() {
198 let line = line.trim();
199
200 let is_constraint = line.contains("MUST")
202 || line.contains("MUST NOT")
203 || line.contains("SHOULD")
204 || line.contains("SHOULD NOT")
205 || line.contains("REQUIRED")
206 || line.contains("constraint")
207 || line.contains("requirement");
208
209 if is_constraint && !line.is_empty() {
210 constraints.push(line.to_string());
211 }
212 }
213}
214
215fn extract_tasks(content: &str, tasks: &mut Vec<String>) {
217 for line in content.lines() {
218 let line = line.trim();
219
220 let is_task = line.starts_with("-")
222 || line.starts_with("*")
223 || line.starts_with("+")
224 || line.contains("TODO")
225 || line.contains("FIXME")
226 || line.contains("implement")
227 || line.contains("fix")
228 || line.contains("add")
229 || line.contains("create")
230 || line.contains("update")
231 || line.contains("remove")
232 || line.contains("delete");
233
234 if is_task && !line.is_empty() {
235 tasks.push(line.to_string());
236 }
237 }
238}
239
240fn build_handoff_prompt(
242 context: &HandoffContext,
243 metadata: &HandoffMetadata,
244 tags: &[String],
245 notes: &str,
246 config: &HandoffConfig,
247) -> String {
248 let mut parts = Vec::new();
249
250 parts.push("# Session Handoff".to_string());
252 parts.push(String::new());
253
254 parts.push("## Session Summary".to_string());
256 parts.push(format!("- **Model:** {}", metadata.model));
257 parts.push(format!("- **Messages:** {}", metadata.message_count));
258 parts.push(format!("- **Tool calls:** {}", metadata.tool_calls));
259 parts.push(format!("- **Files edited:** {}", metadata.files_edited));
260 parts.push(String::new());
261
262 if !tags.is_empty() {
264 parts.push("## Tags".to_string());
265 for tag in tags {
266 parts.push(format!("- {}", tag));
267 }
268 parts.push(String::new());
269 }
270
271 if !notes.is_empty() {
273 parts.push("## Notes".to_string());
274 parts.push(notes.to_string());
275 parts.push(String::new());
276 }
277
278 if !context.file_paths.is_empty() {
280 parts.push("## Files Referenced".to_string());
281 let mut paths = context.file_paths.clone();
282 paths.sort();
283 paths.dedup();
284 for path in paths {
285 parts.push(format!("- {}", path));
286 }
287 parts.push(String::new());
288 }
289
290 if !context.constraints.is_empty() {
292 parts.push("## Constraints & Requirements".to_string());
293 for constraint in context.constraints.iter().take(config.max_constraints) {
294 parts.push(format!("- {}", constraint));
295 }
296 if context.constraints.len() > config.max_constraints {
297 parts.push(format!(
298 "- ... and {} more",
299 context.constraints.len() - config.max_constraints
300 ));
301 }
302 parts.push(String::new());
303 }
304
305 if !context.tasks.is_empty() {
307 parts.push("## Key Tasks & Action Items".to_string());
308 for task in context.tasks.iter().take(config.max_tasks) {
309 let task = task.trim_start_matches(['-', '*', '+']).trim();
311 parts.push(format!("- {}", task));
312 }
313 if context.tasks.len() > config.max_tasks {
314 parts.push(format!(
315 "- ... and {} more",
316 context.tasks.len() - config.max_tasks
317 ));
318 }
319 parts.push(String::new());
320 }
321
322 if !context.recent_messages.is_empty() {
324 parts.push("## Recent Context".to_string());
325 for (role, content) in &context.recent_messages {
326 let role_name = match role {
327 Role::User => "User",
328 Role::Assistant => "Assistant",
329 Role::System => "System",
330 Role::Tool => "Tool",
331 };
332 parts.push(format!("**{}:** {}", role_name, content));
333 }
334 parts.push(String::new());
335 }
336
337 parts.push("---".to_string());
339 parts.push("*Handoff generated for context transfer*".to_string());
340
341 parts.join("\n")
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_generate_handoff_prompt_empty() {
350 let messages = vec![];
351 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
352 assert!(prompt.contains("No conversation context available"));
353 }
354
355 #[test]
356 fn test_generate_handoff_prompt_with_content() {
357 let messages = vec![
358 Message {
359 role: Role::User,
360 content: "Fix src/main.rs".to_string(),
361 tool_calls: vec![],
362 tool_result: None,
363 },
364 Message {
365 role: Role::Assistant,
366 content: "I'll fix it".to_string(),
367 tool_calls: vec![],
368 tool_result: None,
369 },
370 ];
371
372 let prompt = generate_handoff_prompt(&messages, "test-model", 3, 1, &[], "", None);
373
374 assert!(prompt.contains("Session Handoff"));
375 assert!(prompt.contains("Model:"));
376 assert!(prompt.contains("Messages:"));
377 assert!(prompt.contains("Tool calls:"));
378 assert!(prompt.contains("Files edited:"));
379 }
380
381 #[test]
382 fn test_extract_file_paths() {
383 let messages = vec![Message {
384 role: Role::User,
385 content: "Edit src/main.rs and lib/helper.ts".to_string(),
386 tool_calls: vec![],
387 tool_result: None,
388 }];
389
390 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
391
392 assert!(prompt.contains("Files Referenced"));
393 assert!(prompt.contains("src/main.rs"));
394 assert!(prompt.contains("lib/helper.ts"));
395 }
396
397 #[test]
398 fn test_extract_constraints() {
399 let messages = vec![Message {
400 role: Role::User,
401 content: "MUST use async functions\nMUST NOT break existing tests".to_string(),
402 tool_calls: vec![],
403 tool_result: None,
404 }];
405
406 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
407
408 assert!(prompt.contains("Constraints"));
409 assert!(prompt.contains("MUST"));
410 }
411
412 #[test]
413 fn test_extract_tasks() {
414 let messages = vec![Message {
415 role: Role::User,
416 content: "- Implement feature X\n- Fix bug Y\n* Add tests".to_string(),
417 tool_calls: vec![],
418 tool_result: None,
419 }];
420
421 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
422
423 assert!(prompt.contains("Key Tasks"));
424 assert!(prompt.contains("Implement feature X") || prompt.contains("feature X"));
425 }
426
427 #[test]
428 fn test_recent_context() {
429 let messages = vec![
430 Message {
431 role: Role::User,
432 content: "First message".to_string(),
433 tool_calls: vec![],
434 tool_result: None,
435 },
436 Message {
437 role: Role::Assistant,
438 content: "First response".to_string(),
439 tool_calls: vec![],
440 tool_result: None,
441 },
442 Message {
443 role: Role::User,
444 content: "Second message".to_string(),
445 tool_calls: vec![],
446 tool_result: None,
447 },
448 Message {
449 role: Role::Assistant,
450 content: "Second response".to_string(),
451 tool_calls: vec![],
452 tool_result: None,
453 },
454 ];
455
456 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
457
458 assert!(prompt.contains("Recent Context"));
459 assert!(prompt.contains("User") || prompt.contains("Assistant"));
460 }
461
462 #[test]
463 fn test_deduplication() {
464 let messages = vec![
465 Message {
466 role: Role::User,
467 content: "Fix the bug".to_string(),
468 tool_calls: vec![],
469 tool_result: None,
470 },
471 Message {
472 role: Role::User,
473 content: "Fix the bug".to_string(), tool_calls: vec![],
475 tool_result: None,
476 },
477 ];
478
479 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
480
481 assert!(prompt.contains("**Messages:** 2"));
483 }
484
485 #[test]
486 fn test_custom_config() {
487 let messages = vec![Message {
488 role: Role::User,
489 content: "- Task 1\n- Task 2\n- Task 3\n- Task 4\n- Task 5".to_string(),
490 tool_calls: vec![],
491 tool_result: None,
492 }];
493
494 let config = HandoffConfig {
495 max_tasks: 2,
496 ..Default::default()
497 };
498
499 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", Some(config));
500
501 assert!(prompt.contains("Key Tasks"));
502 assert!(prompt.contains("- Task 1"));
504 assert!(prompt.contains("- Task 2"));
505 assert!(!prompt.contains("- Task 3"));
507 }
508}