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 = [".rs", ".ts", ".js", ".py", ".go", ".java", ".md", ".toml", ".json"];
173
174 for word in line.split_whitespace() {
175 let word = word.trim_matches(['\"', '\'', '(', ')', ',', ':', '[', ']']);
176
177 if extensions.iter().any(|ext| word.ends_with(ext)) {
179 file_paths.insert(word.to_string());
180 continue;
181 }
182
183 if word.contains('/') && (word.contains("src") || word.contains("lib") || word.contains("test")) {
185 file_paths.insert(word.to_string());
186 }
187 }
188 }
189}
190
191fn extract_constraints(content: &str, constraints: &mut Vec<String>) {
193 for line in content.lines() {
194 let line = line.trim();
195
196 let is_constraint = line.contains("MUST")
198 || line.contains("MUST NOT")
199 || line.contains("SHOULD")
200 || line.contains("SHOULD NOT")
201 || line.contains("REQUIRED")
202 || line.contains("constraint")
203 || line.contains("requirement");
204
205 if is_constraint && !line.is_empty() {
206 constraints.push(line.to_string());
207 }
208 }
209}
210
211fn extract_tasks(content: &str, tasks: &mut Vec<String>) {
213 for line in content.lines() {
214 let line = line.trim();
215
216 let is_task = line.starts_with("-")
218 || line.starts_with("*")
219 || line.starts_with("+")
220 || line.contains("TODO")
221 || line.contains("FIXME")
222 || line.contains("implement")
223 || line.contains("fix")
224 || line.contains("add")
225 || line.contains("create")
226 || line.contains("update")
227 || line.contains("remove")
228 || line.contains("delete");
229
230 if is_task && !line.is_empty() {
231 tasks.push(line.to_string());
232 }
233 }
234}
235
236fn build_handoff_prompt(
238 context: &HandoffContext,
239 metadata: &HandoffMetadata,
240 tags: &[String],
241 notes: &str,
242 config: &HandoffConfig,
243) -> String {
244 let mut parts = Vec::new();
245
246 parts.push("# Session Handoff".to_string());
248 parts.push(String::new());
249
250 parts.push("## Session Summary".to_string());
252 parts.push(format!("- **Model:** {}", metadata.model));
253 parts.push(format!("- **Messages:** {}", metadata.message_count));
254 parts.push(format!("- **Tool calls:** {}", metadata.tool_calls));
255 parts.push(format!("- **Files edited:** {}", metadata.files_edited));
256 parts.push(String::new());
257
258 if !tags.is_empty() {
260 parts.push("## Tags".to_string());
261 for tag in tags {
262 parts.push(format!("- {}", tag));
263 }
264 parts.push(String::new());
265 }
266
267 if !notes.is_empty() {
269 parts.push("## Notes".to_string());
270 parts.push(notes.to_string());
271 parts.push(String::new());
272 }
273
274 if !context.file_paths.is_empty() {
276 parts.push("## Files Referenced".to_string());
277 let mut paths = context.file_paths.clone();
278 paths.sort();
279 paths.dedup();
280 for path in paths {
281 parts.push(format!("- {}", path));
282 }
283 parts.push(String::new());
284 }
285
286 if !context.constraints.is_empty() {
288 parts.push("## Constraints & Requirements".to_string());
289 for constraint in context.constraints.iter().take(config.max_constraints) {
290 parts.push(format!("- {}", constraint));
291 }
292 if context.constraints.len() > config.max_constraints {
293 parts.push(format!("- ... and {} more", context.constraints.len() - config.max_constraints));
294 }
295 parts.push(String::new());
296 }
297
298 if !context.tasks.is_empty() {
300 parts.push("## Key Tasks & Action Items".to_string());
301 for task in context.tasks.iter().take(config.max_tasks) {
302 let task = task.trim_start_matches(['-', '*', '+']).trim();
304 parts.push(format!("- {}", task));
305 }
306 if context.tasks.len() > config.max_tasks {
307 parts.push(format!("- ... and {} more", context.tasks.len() - config.max_tasks));
308 }
309 parts.push(String::new());
310 }
311
312 if !context.recent_messages.is_empty() {
314 parts.push("## Recent Context".to_string());
315 for (role, content) in &context.recent_messages {
316 let role_name = match role {
317 Role::User => "User",
318 Role::Assistant => "Assistant",
319 Role::System => "System",
320 Role::Tool => "Tool",
321 };
322 parts.push(format!("**{}:** {}", role_name, content));
323 }
324 parts.push(String::new());
325 }
326
327 parts.push("---".to_string());
329 parts.push("*Handoff generated for context transfer*".to_string());
330
331 parts.join("\n")
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_generate_handoff_prompt_empty() {
340 let messages = vec![];
341 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
342 assert!(prompt.contains("No conversation context available"));
343 }
344
345 #[test]
346 fn test_generate_handoff_prompt_with_content() {
347 let messages = vec![
348 Message {
349 role: Role::User,
350 content: "Fix src/main.rs".to_string(),
351 tool_calls: vec![],
352 tool_result: None,
353 },
354 Message {
355 role: Role::Assistant,
356 content: "I'll fix it".to_string(),
357 tool_calls: vec![],
358 tool_result: None,
359 },
360 ];
361
362 let prompt = generate_handoff_prompt(&messages, "test-model", 3, 1, &[], "", None);
363
364 assert!(prompt.contains("Session Handoff"));
365 assert!(prompt.contains("Model:"));
366 assert!(prompt.contains("Messages:"));
367 assert!(prompt.contains("Tool calls:"));
368 assert!(prompt.contains("Files edited:"));
369 }
370
371 #[test]
372 fn test_extract_file_paths() {
373 let messages = vec![
374 Message {
375 role: Role::User,
376 content: "Edit src/main.rs and lib/helper.ts".to_string(),
377 tool_calls: vec![],
378 tool_result: None,
379 },
380 ];
381
382 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
383
384 assert!(prompt.contains("Files Referenced"));
385 assert!(prompt.contains("src/main.rs"));
386 assert!(prompt.contains("lib/helper.ts"));
387 }
388
389 #[test]
390 fn test_extract_constraints() {
391 let messages = vec![
392 Message {
393 role: Role::User,
394 content: "MUST use async functions\nMUST NOT break existing tests".to_string(),
395 tool_calls: vec![],
396 tool_result: None,
397 },
398 ];
399
400 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
401
402 assert!(prompt.contains("Constraints"));
403 assert!(prompt.contains("MUST"));
404 }
405
406 #[test]
407 fn test_extract_tasks() {
408 let messages = vec![
409 Message {
410 role: Role::User,
411 content: "- Implement feature X\n- Fix bug Y\n* Add tests".to_string(),
412 tool_calls: vec![],
413 tool_result: None,
414 },
415 ];
416
417 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
418
419 assert!(prompt.contains("Key Tasks"));
420 assert!(prompt.contains("Implement feature X") || prompt.contains("feature X"));
421 }
422
423 #[test]
424 fn test_recent_context() {
425 let messages = vec![
426 Message {
427 role: Role::User,
428 content: "First message".to_string(),
429 tool_calls: vec![],
430 tool_result: None,
431 },
432 Message {
433 role: Role::Assistant,
434 content: "First response".to_string(),
435 tool_calls: vec![],
436 tool_result: None,
437 },
438 Message {
439 role: Role::User,
440 content: "Second message".to_string(),
441 tool_calls: vec![],
442 tool_result: None,
443 },
444 Message {
445 role: Role::Assistant,
446 content: "Second response".to_string(),
447 tool_calls: vec![],
448 tool_result: None,
449 },
450 ];
451
452 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
453
454 assert!(prompt.contains("Recent Context"));
455 assert!(prompt.contains("User") || prompt.contains("Assistant"));
456 }
457
458 #[test]
459 fn test_deduplication() {
460 let messages = vec![
461 Message {
462 role: Role::User,
463 content: "Fix the bug".to_string(),
464 tool_calls: vec![],
465 tool_result: None,
466 },
467 Message {
468 role: Role::User,
469 content: "Fix the bug".to_string(), tool_calls: vec![],
471 tool_result: None,
472 },
473 ];
474
475 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
476
477 assert!(prompt.contains("**Messages:** 2"));
479 }
480
481 #[test]
482 fn test_custom_config() {
483 let messages = vec![
484 Message {
485 role: Role::User,
486 content: "- Task 1\n- Task 2\n- Task 3\n- Task 4\n- Task 5".to_string(),
487 tool_calls: vec![],
488 tool_result: None,
489 },
490 ];
491
492 let config = HandoffConfig {
493 max_tasks: 2,
494 ..Default::default()
495 };
496
497 let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", Some(config));
498
499 assert!(prompt.contains("Key Tasks"));
500 assert!(prompt.contains("- Task 1"));
502 assert!(prompt.contains("- Task 2"));
503 assert!(!prompt.contains("- Task 3"));
505 }
506}