1use std::collections::{HashMap, HashSet};
7
8use crate::extract::truncate_str;
9use crate::{Content, ContentBlock, Event, EventType, Session, SessionContext, Stats};
10
11#[derive(Debug, Clone)]
15pub struct FileChange {
16 pub path: String,
17 pub action: &'static str,
19}
20
21#[derive(Debug, Clone)]
23pub struct ShellCmd {
24 pub command: String,
25 pub exit_code: Option<i32>,
26}
27
28#[derive(Debug, Clone)]
30pub struct Conversation {
31 pub user: String,
32 pub agent: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct HandoffSummary {
38 pub source_session_id: String,
39 pub objective: String,
40 pub tool: String,
41 pub model: String,
42 pub duration_seconds: u64,
43 pub stats: Stats,
44 pub files_modified: Vec<FileChange>,
45 pub files_read: Vec<String>,
46 pub shell_commands: Vec<ShellCmd>,
47 pub errors: Vec<String>,
48 pub key_conversations: Vec<Conversation>,
49 pub user_messages: Vec<String>,
50}
51
52#[derive(Debug, Clone)]
54pub struct MergedHandoff {
55 pub source_session_ids: Vec<String>,
56 pub summaries: Vec<HandoffSummary>,
57 pub all_files_modified: Vec<FileChange>,
59 pub all_files_read: Vec<String>,
61 pub total_duration_seconds: u64,
62 pub total_errors: Vec<String>,
63}
64
65impl HandoffSummary {
68 pub fn from_session(session: &Session) -> Self {
70 let objective = extract_first_user_text(session)
71 .map(|t| truncate_str(&t, 200))
72 .unwrap_or_else(|| "(no user message found)".to_string());
73
74 let files_modified = collect_file_changes(&session.events);
75 let modified_paths: HashSet<&str> =
76 files_modified.iter().map(|f| f.path.as_str()).collect();
77 let files_read = collect_files_read(&session.events, &modified_paths);
78 let shell_commands = collect_shell_commands(&session.events);
79 let errors = collect_errors(&session.events);
80 let user_messages = collect_user_messages(&session.events);
81 let key_conversations = collect_conversation_pairs(&session.events);
82
83 HandoffSummary {
84 source_session_id: session.session_id.clone(),
85 objective,
86 tool: session.agent.tool.clone(),
87 model: session.agent.model.clone(),
88 duration_seconds: session.stats.duration_seconds,
89 stats: session.stats.clone(),
90 files_modified,
91 files_read,
92 shell_commands,
93 errors,
94 key_conversations,
95 user_messages,
96 }
97 }
98}
99
100fn collect_file_changes(events: &[Event]) -> Vec<FileChange> {
104 let map = events.iter().fold(HashMap::new(), |mut map, event| {
105 match &event.event_type {
106 EventType::FileCreate { path } => {
107 map.insert(path.clone(), "created");
108 }
109 EventType::FileEdit { path, .. } => {
110 map.entry(path.clone()).or_insert("edited");
111 }
112 EventType::FileDelete { path } => {
113 map.insert(path.clone(), "deleted");
114 }
115 _ => {}
116 }
117 map
118 });
119 let mut result: Vec<FileChange> = map
120 .into_iter()
121 .map(|(path, action)| FileChange { path, action })
122 .collect();
123 result.sort_by(|a, b| a.path.cmp(&b.path));
124 result
125}
126
127fn collect_files_read(events: &[Event], modified_paths: &HashSet<&str>) -> Vec<String> {
129 let mut read: Vec<String> = events
130 .iter()
131 .filter_map(|e| match &e.event_type {
132 EventType::FileRead { path } if !modified_paths.contains(path.as_str()) => {
133 Some(path.clone())
134 }
135 _ => None,
136 })
137 .collect::<HashSet<_>>()
138 .into_iter()
139 .collect();
140 read.sort();
141 read
142}
143
144fn collect_shell_commands(events: &[Event]) -> Vec<ShellCmd> {
145 events
146 .iter()
147 .filter_map(|event| match &event.event_type {
148 EventType::ShellCommand { command, exit_code } => Some(ShellCmd {
149 command: command.clone(),
150 exit_code: *exit_code,
151 }),
152 _ => None,
153 })
154 .collect()
155}
156
157fn collect_errors(events: &[Event]) -> Vec<String> {
159 events
160 .iter()
161 .filter_map(|event| match &event.event_type {
162 EventType::ShellCommand { command, exit_code }
163 if *exit_code != Some(0) && exit_code.is_some() =>
164 {
165 Some(format!(
166 "Shell: `{}` → exit {}",
167 truncate_str(command, 80),
168 exit_code.unwrap()
169 ))
170 }
171 EventType::ToolResult {
172 is_error: true,
173 name,
174 ..
175 } => {
176 let detail = extract_text_from_event(event);
177 Some(match detail {
178 Some(d) => format!("Tool error: {} — {}", name, truncate_str(&d, 80)),
179 None => format!("Tool error: {name}"),
180 })
181 }
182 _ => None,
183 })
184 .collect()
185}
186
187fn collect_user_messages(events: &[Event]) -> Vec<String> {
188 events
189 .iter()
190 .filter(|e| matches!(&e.event_type, EventType::UserMessage))
191 .filter_map(extract_text_from_event)
192 .collect()
193}
194
195fn collect_conversation_pairs(events: &[Event]) -> Vec<Conversation> {
200 let messages: Vec<&Event> = events
201 .iter()
202 .filter(|e| {
203 matches!(
204 &e.event_type,
205 EventType::UserMessage | EventType::AgentMessage
206 )
207 })
208 .collect();
209
210 messages
211 .windows(2)
212 .filter_map(|pair| match (&pair[0].event_type, &pair[1].event_type) {
213 (EventType::UserMessage, EventType::AgentMessage) => {
214 let user_text = extract_text_from_event(pair[0])?;
215 let agent_text = extract_text_from_event(pair[1])?;
216 Some(Conversation {
217 user: truncate_str(&user_text, 300),
218 agent: truncate_str(&agent_text, 300),
219 })
220 }
221 _ => None,
222 })
223 .collect()
224}
225
226pub fn merge_summaries(summaries: &[HandoffSummary]) -> MergedHandoff {
230 let session_ids: Vec<String> = summaries
231 .iter()
232 .map(|s| s.source_session_id.clone())
233 .collect();
234 let total_duration: u64 = summaries.iter().map(|s| s.duration_seconds).sum();
235 let total_errors: Vec<String> = summaries
236 .iter()
237 .flat_map(|s| {
238 s.errors
239 .iter()
240 .map(move |err| format!("[{}] {}", s.source_session_id, err))
241 })
242 .collect();
243
244 let all_modified: HashMap<String, &str> = summaries
245 .iter()
246 .flat_map(|s| &s.files_modified)
247 .fold(HashMap::new(), |mut map, fc| {
248 map.entry(fc.path.clone()).or_insert(fc.action);
249 map
250 });
251
252 let mut sorted_read: Vec<String> = summaries
254 .iter()
255 .flat_map(|s| &s.files_read)
256 .filter(|p| !all_modified.contains_key(p.as_str()))
257 .cloned()
258 .collect::<HashSet<_>>()
259 .into_iter()
260 .collect();
261 sorted_read.sort();
262
263 let mut sorted_modified: Vec<FileChange> = all_modified
264 .into_iter()
265 .map(|(path, action)| FileChange { path, action })
266 .collect();
267 sorted_modified.sort_by(|a, b| a.path.cmp(&b.path));
268
269 MergedHandoff {
270 source_session_ids: session_ids,
271 summaries: summaries.to_vec(),
272 all_files_modified: sorted_modified,
273 all_files_read: sorted_read,
274 total_duration_seconds: total_duration,
275 total_errors,
276 }
277}
278
279pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String {
283 let mut md = String::new();
284
285 md.push_str("# Session Handoff\n\n");
286
287 md.push_str("## Objective\n");
289 md.push_str(&summary.objective);
290 md.push_str("\n\n");
291
292 md.push_str("## Summary\n");
294 md.push_str(&format!(
295 "- **Tool:** {} ({})\n",
296 summary.tool, summary.model
297 ));
298 md.push_str(&format!(
299 "- **Duration:** {}\n",
300 format_duration(summary.duration_seconds)
301 ));
302 md.push_str(&format!(
303 "- **Messages:** {} | Tool calls: {} | Events: {}\n",
304 summary.stats.message_count, summary.stats.tool_call_count, summary.stats.event_count
305 ));
306 md.push('\n');
307
308 if !summary.files_modified.is_empty() {
310 md.push_str("## Files Modified\n");
311 for fc in &summary.files_modified {
312 md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
313 }
314 md.push('\n');
315 }
316
317 if !summary.files_read.is_empty() {
319 md.push_str("## Files Read\n");
320 for path in &summary.files_read {
321 md.push_str(&format!("- `{path}`\n"));
322 }
323 md.push('\n');
324 }
325
326 if !summary.shell_commands.is_empty() {
328 md.push_str("## Shell Commands\n");
329 for cmd in &summary.shell_commands {
330 let code_str = match cmd.exit_code {
331 Some(c) => c.to_string(),
332 None => "?".to_string(),
333 };
334 md.push_str(&format!(
335 "- `{}` → {}\n",
336 truncate_str(&cmd.command, 80),
337 code_str
338 ));
339 }
340 md.push('\n');
341 }
342
343 if !summary.errors.is_empty() {
345 md.push_str("## Errors\n");
346 for err in &summary.errors {
347 md.push_str(&format!("- {err}\n"));
348 }
349 md.push('\n');
350 }
351
352 if !summary.key_conversations.is_empty() {
354 md.push_str("## Key Conversations\n");
355 for (i, conv) in summary.key_conversations.iter().enumerate() {
356 md.push_str(&format!(
357 "### {}. User\n{}\n\n### {}. Agent\n{}\n\n",
358 i + 1,
359 truncate_str(&conv.user, 300),
360 i + 1,
361 truncate_str(&conv.agent, 300),
362 ));
363 }
364 }
365
366 if summary.key_conversations.is_empty() && !summary.user_messages.is_empty() {
368 md.push_str("## User Messages\n");
369 for (i, msg) in summary.user_messages.iter().enumerate() {
370 md.push_str(&format!("{}. {}\n", i + 1, truncate_str(msg, 150)));
371 }
372 md.push('\n');
373 }
374
375 md
376}
377
378pub fn generate_merged_handoff_markdown(merged: &MergedHandoff) -> String {
380 let mut md = String::new();
381
382 md.push_str("# Merged Session Handoff\n\n");
383 md.push_str(&format!(
384 "**Sessions:** {} | **Total Duration:** {}\n\n",
385 merged.source_session_ids.len(),
386 format_duration(merged.total_duration_seconds)
387 ));
388
389 for (i, s) in merged.summaries.iter().enumerate() {
391 md.push_str(&format!(
392 "---\n\n## Session {} — {}\n\n",
393 i + 1,
394 s.source_session_id
395 ));
396 md.push_str(&format!("**Objective:** {}\n\n", s.objective));
397 md.push_str(&format!(
398 "- **Tool:** {} ({}) | **Duration:** {}\n",
399 s.tool,
400 s.model,
401 format_duration(s.duration_seconds)
402 ));
403 md.push_str(&format!(
404 "- **Messages:** {} | Tool calls: {} | Events: {}\n\n",
405 s.stats.message_count, s.stats.tool_call_count, s.stats.event_count
406 ));
407
408 if !s.key_conversations.is_empty() {
410 md.push_str("### Conversations\n");
411 for (j, conv) in s.key_conversations.iter().enumerate() {
412 md.push_str(&format!(
413 "**{}. User:** {}\n\n**{}. Agent:** {}\n\n",
414 j + 1,
415 truncate_str(&conv.user, 200),
416 j + 1,
417 truncate_str(&conv.agent, 200),
418 ));
419 }
420 }
421 }
422
423 md.push_str("---\n\n## All Files Modified\n");
425 if merged.all_files_modified.is_empty() {
426 md.push_str("_(none)_\n");
427 } else {
428 for fc in &merged.all_files_modified {
429 md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
430 }
431 }
432 md.push('\n');
433
434 if !merged.all_files_read.is_empty() {
435 md.push_str("## All Files Read\n");
436 for path in &merged.all_files_read {
437 md.push_str(&format!("- `{path}`\n"));
438 }
439 md.push('\n');
440 }
441
442 if !merged.total_errors.is_empty() {
444 md.push_str("## All Errors\n");
445 for err in &merged.total_errors {
446 md.push_str(&format!("- {err}\n"));
447 }
448 md.push('\n');
449 }
450
451 md
452}
453
454pub fn generate_handoff_hail(session: &Session) -> Session {
460 let mut summary_session = Session {
461 version: session.version.clone(),
462 session_id: format!("handoff-{}", session.session_id),
463 agent: session.agent.clone(),
464 context: SessionContext {
465 title: Some(format!(
466 "Handoff: {}",
467 session.context.title.as_deref().unwrap_or("(untitled)")
468 )),
469 description: session.context.description.clone(),
470 tags: {
471 let mut tags = session.context.tags.clone();
472 if !tags.contains(&"handoff".to_string()) {
473 tags.push("handoff".to_string());
474 }
475 tags
476 },
477 created_at: session.context.created_at,
478 updated_at: chrono::Utc::now(),
479 related_session_ids: vec![session.session_id.clone()],
480 attributes: HashMap::new(),
481 },
482 events: Vec::new(),
483 stats: session.stats.clone(),
484 };
485
486 for event in &session.events {
487 let keep = matches!(
488 &event.event_type,
489 EventType::UserMessage
490 | EventType::AgentMessage
491 | EventType::FileEdit { .. }
492 | EventType::FileCreate { .. }
493 | EventType::FileDelete { .. }
494 | EventType::TaskStart { .. }
495 | EventType::TaskEnd { .. }
496 ) || matches!(&event.event_type, EventType::ShellCommand { exit_code, .. } if *exit_code != Some(0));
497
498 if !keep {
499 continue;
500 }
501
502 let truncated_blocks: Vec<ContentBlock> = event
504 .content
505 .blocks
506 .iter()
507 .map(|block| match block {
508 ContentBlock::Text { text } => ContentBlock::Text {
509 text: truncate_str(text, 300),
510 },
511 ContentBlock::Code {
512 code,
513 language,
514 start_line,
515 } => ContentBlock::Code {
516 code: truncate_str(code, 300),
517 language: language.clone(),
518 start_line: *start_line,
519 },
520 other => other.clone(),
521 })
522 .collect();
523
524 summary_session.events.push(Event {
525 event_id: event.event_id.clone(),
526 timestamp: event.timestamp,
527 event_type: event.event_type.clone(),
528 task_id: event.task_id.clone(),
529 content: Content {
530 blocks: truncated_blocks,
531 },
532 duration_ms: event.duration_ms,
533 attributes: HashMap::new(), });
535 }
536
537 summary_session.recompute_stats();
539
540 summary_session
541}
542
543fn extract_first_user_text(session: &Session) -> Option<String> {
546 crate::extract::extract_first_user_text(session)
547}
548
549fn extract_text_from_event(event: &Event) -> Option<String> {
550 for block in &event.content.blocks {
551 if let ContentBlock::Text { text } = block {
552 let trimmed = text.trim();
553 if !trimmed.is_empty() {
554 return Some(trimmed.to_string());
555 }
556 }
557 }
558 None
559}
560
561pub fn format_duration(seconds: u64) -> String {
563 if seconds < 60 {
564 format!("{seconds}s")
565 } else if seconds < 3600 {
566 let m = seconds / 60;
567 let s = seconds % 60;
568 format!("{m}m {s}s")
569 } else {
570 let h = seconds / 3600;
571 let m = (seconds % 3600) / 60;
572 let s = seconds % 60;
573 format!("{h}h {m}m {s}s")
574 }
575}
576
577#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::{testing, Agent};
583
584 fn make_agent() -> Agent {
585 testing::agent()
586 }
587
588 fn make_event(event_type: EventType, text: &str) -> Event {
589 testing::event(event_type, text)
590 }
591
592 #[test]
593 fn test_format_duration() {
594 assert_eq!(format_duration(0), "0s");
595 assert_eq!(format_duration(45), "45s");
596 assert_eq!(format_duration(90), "1m 30s");
597 assert_eq!(format_duration(750), "12m 30s");
598 assert_eq!(format_duration(3661), "1h 1m 1s");
599 }
600
601 #[test]
602 fn test_handoff_summary_from_session() {
603 let mut session = Session::new("test-id".to_string(), make_agent());
604 session.stats = Stats {
605 event_count: 10,
606 message_count: 3,
607 tool_call_count: 5,
608 duration_seconds: 750,
609 ..Default::default()
610 };
611 session
612 .events
613 .push(make_event(EventType::UserMessage, "Fix the build error"));
614 session
615 .events
616 .push(make_event(EventType::AgentMessage, "I'll fix it now"));
617 session.events.push(make_event(
618 EventType::FileEdit {
619 path: "src/main.rs".to_string(),
620 diff: None,
621 },
622 "",
623 ));
624 session.events.push(make_event(
625 EventType::FileRead {
626 path: "Cargo.toml".to_string(),
627 },
628 "",
629 ));
630 session.events.push(make_event(
631 EventType::ShellCommand {
632 command: "cargo build".to_string(),
633 exit_code: Some(0),
634 },
635 "",
636 ));
637
638 let summary = HandoffSummary::from_session(&session);
639
640 assert_eq!(summary.source_session_id, "test-id");
641 assert_eq!(summary.objective, "Fix the build error");
642 assert_eq!(summary.files_modified.len(), 1);
643 assert_eq!(summary.files_modified[0].path, "src/main.rs");
644 assert_eq!(summary.files_modified[0].action, "edited");
645 assert_eq!(summary.files_read, vec!["Cargo.toml"]);
646 assert_eq!(summary.shell_commands.len(), 1);
647 assert_eq!(summary.key_conversations.len(), 1);
648 assert_eq!(summary.key_conversations[0].user, "Fix the build error");
649 assert_eq!(summary.key_conversations[0].agent, "I'll fix it now");
650 }
651
652 #[test]
653 fn test_files_read_excludes_modified() {
654 let mut session = Session::new("test-id".to_string(), make_agent());
655 session
656 .events
657 .push(make_event(EventType::UserMessage, "test"));
658 session.events.push(make_event(
659 EventType::FileRead {
660 path: "src/main.rs".to_string(),
661 },
662 "",
663 ));
664 session.events.push(make_event(
665 EventType::FileEdit {
666 path: "src/main.rs".to_string(),
667 diff: None,
668 },
669 "",
670 ));
671 session.events.push(make_event(
672 EventType::FileRead {
673 path: "README.md".to_string(),
674 },
675 "",
676 ));
677
678 let summary = HandoffSummary::from_session(&session);
679 assert_eq!(summary.files_read, vec!["README.md"]);
680 assert_eq!(summary.files_modified.len(), 1);
681 }
682
683 #[test]
684 fn test_file_create_not_overwritten_by_edit() {
685 let mut session = Session::new("test-id".to_string(), make_agent());
686 session
687 .events
688 .push(make_event(EventType::UserMessage, "test"));
689 session.events.push(make_event(
690 EventType::FileCreate {
691 path: "new_file.rs".to_string(),
692 },
693 "",
694 ));
695 session.events.push(make_event(
696 EventType::FileEdit {
697 path: "new_file.rs".to_string(),
698 diff: None,
699 },
700 "",
701 ));
702
703 let summary = HandoffSummary::from_session(&session);
704 assert_eq!(summary.files_modified[0].action, "created");
705 }
706
707 #[test]
708 fn test_shell_error_captured() {
709 let mut session = Session::new("test-id".to_string(), make_agent());
710 session
711 .events
712 .push(make_event(EventType::UserMessage, "test"));
713 session.events.push(make_event(
714 EventType::ShellCommand {
715 command: "cargo test".to_string(),
716 exit_code: Some(1),
717 },
718 "",
719 ));
720
721 let summary = HandoffSummary::from_session(&session);
722 assert_eq!(summary.errors.len(), 1);
723 assert!(summary.errors[0].contains("cargo test"));
724 }
725
726 #[test]
727 fn test_generate_handoff_markdown() {
728 let mut session = Session::new("test-id".to_string(), make_agent());
729 session.stats = Stats {
730 event_count: 10,
731 message_count: 3,
732 tool_call_count: 5,
733 duration_seconds: 750,
734 ..Default::default()
735 };
736 session
737 .events
738 .push(make_event(EventType::UserMessage, "Fix the build error"));
739 session
740 .events
741 .push(make_event(EventType::AgentMessage, "I'll fix it now"));
742 session.events.push(make_event(
743 EventType::FileEdit {
744 path: "src/main.rs".to_string(),
745 diff: None,
746 },
747 "",
748 ));
749 session.events.push(make_event(
750 EventType::ShellCommand {
751 command: "cargo build".to_string(),
752 exit_code: Some(0),
753 },
754 "",
755 ));
756
757 let summary = HandoffSummary::from_session(&session);
758 let md = generate_handoff_markdown(&summary);
759
760 assert!(md.contains("# Session Handoff"));
761 assert!(md.contains("Fix the build error"));
762 assert!(md.contains("claude-code (claude-opus-4-6)"));
763 assert!(md.contains("12m 30s"));
764 assert!(md.contains("`src/main.rs` (edited)"));
765 assert!(md.contains("`cargo build` → 0"));
766 assert!(md.contains("## Key Conversations"));
767 }
768
769 #[test]
770 fn test_merge_summaries() {
771 let mut s1 = Session::new("session-a".to_string(), make_agent());
772 s1.stats.duration_seconds = 100;
773 s1.events.push(make_event(EventType::UserMessage, "task A"));
774 s1.events.push(make_event(
775 EventType::FileEdit {
776 path: "a.rs".to_string(),
777 diff: None,
778 },
779 "",
780 ));
781
782 let mut s2 = Session::new("session-b".to_string(), make_agent());
783 s2.stats.duration_seconds = 200;
784 s2.events.push(make_event(EventType::UserMessage, "task B"));
785 s2.events.push(make_event(
786 EventType::FileEdit {
787 path: "b.rs".to_string(),
788 diff: None,
789 },
790 "",
791 ));
792
793 let sum1 = HandoffSummary::from_session(&s1);
794 let sum2 = HandoffSummary::from_session(&s2);
795 let merged = merge_summaries(&[sum1, sum2]);
796
797 assert_eq!(merged.source_session_ids.len(), 2);
798 assert_eq!(merged.total_duration_seconds, 300);
799 assert_eq!(merged.all_files_modified.len(), 2);
800 }
801
802 #[test]
803 fn test_generate_handoff_hail() {
804 let mut session = Session::new("test-id".to_string(), make_agent());
805 session
806 .events
807 .push(make_event(EventType::UserMessage, "Hello"));
808 session
809 .events
810 .push(make_event(EventType::AgentMessage, "Hi there"));
811 session.events.push(make_event(
812 EventType::FileRead {
813 path: "foo.rs".to_string(),
814 },
815 "",
816 ));
817 session.events.push(make_event(
818 EventType::FileEdit {
819 path: "foo.rs".to_string(),
820 diff: Some("+added line".to_string()),
821 },
822 "",
823 ));
824 session.events.push(make_event(
825 EventType::ShellCommand {
826 command: "cargo build".to_string(),
827 exit_code: Some(0),
828 },
829 "",
830 ));
831
832 let hail = generate_handoff_hail(&session);
833
834 assert!(hail.session_id.starts_with("handoff-"));
835 assert_eq!(hail.context.related_session_ids, vec!["test-id"]);
836 assert!(hail.context.tags.contains(&"handoff".to_string()));
837 assert_eq!(hail.events.len(), 3); let jsonl = hail.to_jsonl().unwrap();
841 let parsed = Session::from_jsonl(&jsonl).unwrap();
842 assert_eq!(parsed.session_id, hail.session_id);
843 }
844}