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