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 task_summaries: Vec<String>,
49 pub key_conversations: Vec<Conversation>,
50 pub user_messages: Vec<String>,
51}
52
53#[derive(Debug, Clone)]
55pub struct MergedHandoff {
56 pub source_session_ids: Vec<String>,
57 pub summaries: Vec<HandoffSummary>,
58 pub all_files_modified: Vec<FileChange>,
60 pub all_files_read: Vec<String>,
62 pub total_duration_seconds: u64,
63 pub total_errors: Vec<String>,
64}
65
66impl HandoffSummary {
69 pub fn from_session(session: &Session) -> Self {
71 let objective = extract_objective(session);
72
73 let files_modified = collect_file_changes(&session.events);
74 let modified_paths: HashSet<&str> =
75 files_modified.iter().map(|f| f.path.as_str()).collect();
76 let files_read = collect_files_read(&session.events, &modified_paths);
77 let shell_commands = collect_shell_commands(&session.events);
78 let errors = collect_errors(&session.events);
79 let task_summaries = collect_task_summaries(&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 task_summaries,
95 key_conversations,
96 user_messages,
97 }
98 }
99}
100
101fn collect_file_changes(events: &[Event]) -> Vec<FileChange> {
105 let map = events.iter().fold(HashMap::new(), |mut map, event| {
106 match &event.event_type {
107 EventType::FileCreate { path } => {
108 map.insert(path.clone(), "created");
109 }
110 EventType::FileEdit { path, .. } => {
111 map.entry(path.clone()).or_insert("edited");
112 }
113 EventType::FileDelete { path } => {
114 map.insert(path.clone(), "deleted");
115 }
116 _ => {}
117 }
118 map
119 });
120 let mut result: Vec<FileChange> = map
121 .into_iter()
122 .map(|(path, action)| FileChange { path, action })
123 .collect();
124 result.sort_by(|a, b| a.path.cmp(&b.path));
125 result
126}
127
128fn collect_files_read(events: &[Event], modified_paths: &HashSet<&str>) -> Vec<String> {
130 let mut read: Vec<String> = events
131 .iter()
132 .filter_map(|e| match &e.event_type {
133 EventType::FileRead { path } if !modified_paths.contains(path.as_str()) => {
134 Some(path.clone())
135 }
136 _ => None,
137 })
138 .collect::<HashSet<_>>()
139 .into_iter()
140 .collect();
141 read.sort();
142 read
143}
144
145fn collect_shell_commands(events: &[Event]) -> Vec<ShellCmd> {
146 events
147 .iter()
148 .filter_map(|event| match &event.event_type {
149 EventType::ShellCommand { command, exit_code } => Some(ShellCmd {
150 command: command.clone(),
151 exit_code: *exit_code,
152 }),
153 _ => None,
154 })
155 .collect()
156}
157
158fn collect_errors(events: &[Event]) -> Vec<String> {
160 events
161 .iter()
162 .filter_map(|event| match &event.event_type {
163 EventType::ShellCommand { command, exit_code }
164 if *exit_code != Some(0) && exit_code.is_some() =>
165 {
166 Some(format!(
167 "Shell: `{}` → exit {}",
168 truncate_str(command, 80),
169 exit_code.unwrap()
170 ))
171 }
172 EventType::ToolResult {
173 is_error: true,
174 name,
175 ..
176 } => {
177 let detail = extract_text_from_event(event);
178 Some(match detail {
179 Some(d) => format!("Tool error: {} — {}", name, truncate_str(&d, 80)),
180 None => format!("Tool error: {name}"),
181 })
182 }
183 _ => None,
184 })
185 .collect()
186}
187
188fn collect_task_summaries(events: &[Event]) -> Vec<String> {
189 let mut seen = HashSet::new();
190 let mut summaries = Vec::new();
191
192 for event in events {
193 let EventType::TaskEnd {
194 summary: Some(summary),
195 } = &event.event_type
196 else {
197 continue;
198 };
199
200 let summary = summary.trim();
201 if summary.is_empty() {
202 continue;
203 }
204
205 let normalized = collapse_whitespace(summary);
206 if normalized.eq_ignore_ascii_case("synthetic end (missing task_complete)") {
207 continue;
208 }
209 if seen.insert(normalized.clone()) {
210 summaries.push(truncate_str(&normalized, 180));
211 }
212 }
213
214 summaries
215}
216
217fn collect_user_messages(events: &[Event]) -> Vec<String> {
218 events
219 .iter()
220 .filter(|e| matches!(&e.event_type, EventType::UserMessage))
221 .filter_map(extract_text_from_event)
222 .collect()
223}
224
225fn collect_conversation_pairs(events: &[Event]) -> Vec<Conversation> {
230 let messages: Vec<&Event> = events
231 .iter()
232 .filter(|e| {
233 matches!(
234 &e.event_type,
235 EventType::UserMessage | EventType::AgentMessage
236 )
237 })
238 .collect();
239
240 messages
241 .windows(2)
242 .filter_map(|pair| match (&pair[0].event_type, &pair[1].event_type) {
243 (EventType::UserMessage, EventType::AgentMessage) => {
244 let user_text = extract_text_from_event(pair[0])?;
245 let agent_text = extract_text_from_event(pair[1])?;
246 Some(Conversation {
247 user: truncate_str(&user_text, 300),
248 agent: truncate_str(&agent_text, 300),
249 })
250 }
251 _ => None,
252 })
253 .collect()
254}
255
256pub fn merge_summaries(summaries: &[HandoffSummary]) -> MergedHandoff {
260 let session_ids: Vec<String> = summaries
261 .iter()
262 .map(|s| s.source_session_id.clone())
263 .collect();
264 let total_duration: u64 = summaries.iter().map(|s| s.duration_seconds).sum();
265 let total_errors: Vec<String> = summaries
266 .iter()
267 .flat_map(|s| {
268 s.errors
269 .iter()
270 .map(move |err| format!("[{}] {}", s.source_session_id, err))
271 })
272 .collect();
273
274 let all_modified: HashMap<String, &str> = summaries
275 .iter()
276 .flat_map(|s| &s.files_modified)
277 .fold(HashMap::new(), |mut map, fc| {
278 map.entry(fc.path.clone()).or_insert(fc.action);
279 map
280 });
281
282 let mut sorted_read: Vec<String> = summaries
284 .iter()
285 .flat_map(|s| &s.files_read)
286 .filter(|p| !all_modified.contains_key(p.as_str()))
287 .cloned()
288 .collect::<HashSet<_>>()
289 .into_iter()
290 .collect();
291 sorted_read.sort();
292
293 let mut sorted_modified: Vec<FileChange> = all_modified
294 .into_iter()
295 .map(|(path, action)| FileChange { path, action })
296 .collect();
297 sorted_modified.sort_by(|a, b| a.path.cmp(&b.path));
298
299 MergedHandoff {
300 source_session_ids: session_ids,
301 summaries: summaries.to_vec(),
302 all_files_modified: sorted_modified,
303 all_files_read: sorted_read,
304 total_duration_seconds: total_duration,
305 total_errors,
306 }
307}
308
309pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String {
313 const MAX_TASK_SUMMARIES_DISPLAY: usize = 5;
314 let mut md = String::new();
315
316 md.push_str("# Session Handoff\n\n");
317
318 md.push_str("## Objective\n");
320 md.push_str(&summary.objective);
321 md.push_str("\n\n");
322
323 md.push_str("## Summary\n");
325 md.push_str(&format!(
326 "- **Tool:** {} ({})\n",
327 summary.tool, summary.model
328 ));
329 md.push_str(&format!(
330 "- **Duration:** {}\n",
331 format_duration(summary.duration_seconds)
332 ));
333 md.push_str(&format!(
334 "- **Messages:** {} | Tool calls: {} | Events: {}\n",
335 summary.stats.message_count, summary.stats.tool_call_count, summary.stats.event_count
336 ));
337 md.push('\n');
338
339 if !summary.task_summaries.is_empty() {
340 md.push_str("## Task Summaries\n");
341 for (idx, task_summary) in summary
342 .task_summaries
343 .iter()
344 .take(MAX_TASK_SUMMARIES_DISPLAY)
345 .enumerate()
346 {
347 md.push_str(&format!("{}. {}\n", idx + 1, task_summary));
348 }
349 if summary.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY {
350 md.push_str(&format!(
351 "- ... and {} more\n",
352 summary.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY
353 ));
354 }
355 md.push('\n');
356 }
357
358 if !summary.files_modified.is_empty() {
360 md.push_str("## Files Modified\n");
361 for fc in &summary.files_modified {
362 md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
363 }
364 md.push('\n');
365 }
366
367 if !summary.files_read.is_empty() {
369 md.push_str("## Files Read\n");
370 for path in &summary.files_read {
371 md.push_str(&format!("- `{path}`\n"));
372 }
373 md.push('\n');
374 }
375
376 if !summary.shell_commands.is_empty() {
378 md.push_str("## Shell Commands\n");
379 for cmd in &summary.shell_commands {
380 let code_str = match cmd.exit_code {
381 Some(c) => c.to_string(),
382 None => "?".to_string(),
383 };
384 md.push_str(&format!(
385 "- `{}` → {}\n",
386 truncate_str(&cmd.command, 80),
387 code_str
388 ));
389 }
390 md.push('\n');
391 }
392
393 if !summary.errors.is_empty() {
395 md.push_str("## Errors\n");
396 for err in &summary.errors {
397 md.push_str(&format!("- {err}\n"));
398 }
399 md.push('\n');
400 }
401
402 if !summary.key_conversations.is_empty() {
404 md.push_str("## Key Conversations\n");
405 for (i, conv) in summary.key_conversations.iter().enumerate() {
406 md.push_str(&format!(
407 "### {}. User\n{}\n\n### {}. Agent\n{}\n\n",
408 i + 1,
409 truncate_str(&conv.user, 300),
410 i + 1,
411 truncate_str(&conv.agent, 300),
412 ));
413 }
414 }
415
416 if summary.key_conversations.is_empty() && !summary.user_messages.is_empty() {
418 md.push_str("## User Messages\n");
419 for (i, msg) in summary.user_messages.iter().enumerate() {
420 md.push_str(&format!("{}. {}\n", i + 1, truncate_str(msg, 150)));
421 }
422 md.push('\n');
423 }
424
425 md
426}
427
428pub fn generate_merged_handoff_markdown(merged: &MergedHandoff) -> String {
430 const MAX_TASK_SUMMARIES_DISPLAY: usize = 3;
431 let mut md = String::new();
432
433 md.push_str("# Merged Session Handoff\n\n");
434 md.push_str(&format!(
435 "**Sessions:** {} | **Total Duration:** {}\n\n",
436 merged.source_session_ids.len(),
437 format_duration(merged.total_duration_seconds)
438 ));
439
440 for (i, s) in merged.summaries.iter().enumerate() {
442 md.push_str(&format!(
443 "---\n\n## Session {} — {}\n\n",
444 i + 1,
445 s.source_session_id
446 ));
447 md.push_str(&format!("**Objective:** {}\n\n", s.objective));
448 md.push_str(&format!(
449 "- **Tool:** {} ({}) | **Duration:** {}\n",
450 s.tool,
451 s.model,
452 format_duration(s.duration_seconds)
453 ));
454 md.push_str(&format!(
455 "- **Messages:** {} | Tool calls: {} | Events: {}\n\n",
456 s.stats.message_count, s.stats.tool_call_count, s.stats.event_count
457 ));
458
459 if !s.task_summaries.is_empty() {
460 md.push_str("### Task Summaries\n");
461 for (j, task_summary) in s
462 .task_summaries
463 .iter()
464 .take(MAX_TASK_SUMMARIES_DISPLAY)
465 .enumerate()
466 {
467 md.push_str(&format!("{}. {}\n", j + 1, task_summary));
468 }
469 if s.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY {
470 md.push_str(&format!(
471 "- ... and {} more\n",
472 s.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY
473 ));
474 }
475 md.push('\n');
476 }
477
478 if !s.key_conversations.is_empty() {
480 md.push_str("### Conversations\n");
481 for (j, conv) in s.key_conversations.iter().enumerate() {
482 md.push_str(&format!(
483 "**{}. User:** {}\n\n**{}. Agent:** {}\n\n",
484 j + 1,
485 truncate_str(&conv.user, 200),
486 j + 1,
487 truncate_str(&conv.agent, 200),
488 ));
489 }
490 }
491 }
492
493 md.push_str("---\n\n## All Files Modified\n");
495 if merged.all_files_modified.is_empty() {
496 md.push_str("_(none)_\n");
497 } else {
498 for fc in &merged.all_files_modified {
499 md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
500 }
501 }
502 md.push('\n');
503
504 if !merged.all_files_read.is_empty() {
505 md.push_str("## All Files Read\n");
506 for path in &merged.all_files_read {
507 md.push_str(&format!("- `{path}`\n"));
508 }
509 md.push('\n');
510 }
511
512 if !merged.total_errors.is_empty() {
514 md.push_str("## All Errors\n");
515 for err in &merged.total_errors {
516 md.push_str(&format!("- {err}\n"));
517 }
518 md.push('\n');
519 }
520
521 md
522}
523
524pub fn generate_handoff_hail(session: &Session) -> Session {
530 let mut summary_session = Session {
531 version: session.version.clone(),
532 session_id: format!("handoff-{}", session.session_id),
533 agent: session.agent.clone(),
534 context: SessionContext {
535 title: Some(format!(
536 "Handoff: {}",
537 session.context.title.as_deref().unwrap_or("(untitled)")
538 )),
539 description: session.context.description.clone(),
540 tags: {
541 let mut tags = session.context.tags.clone();
542 if !tags.contains(&"handoff".to_string()) {
543 tags.push("handoff".to_string());
544 }
545 tags
546 },
547 created_at: session.context.created_at,
548 updated_at: chrono::Utc::now(),
549 related_session_ids: vec![session.session_id.clone()],
550 attributes: HashMap::new(),
551 },
552 events: Vec::new(),
553 stats: session.stats.clone(),
554 };
555
556 for event in &session.events {
557 let keep = matches!(
558 &event.event_type,
559 EventType::UserMessage
560 | EventType::AgentMessage
561 | EventType::FileEdit { .. }
562 | EventType::FileCreate { .. }
563 | EventType::FileDelete { .. }
564 | EventType::TaskStart { .. }
565 | EventType::TaskEnd { .. }
566 ) || matches!(&event.event_type, EventType::ShellCommand { exit_code, .. } if *exit_code != Some(0));
567
568 if !keep {
569 continue;
570 }
571
572 let truncated_blocks: Vec<ContentBlock> = event
574 .content
575 .blocks
576 .iter()
577 .map(|block| match block {
578 ContentBlock::Text { text } => ContentBlock::Text {
579 text: truncate_str(text, 300),
580 },
581 ContentBlock::Code {
582 code,
583 language,
584 start_line,
585 } => ContentBlock::Code {
586 code: truncate_str(code, 300),
587 language: language.clone(),
588 start_line: *start_line,
589 },
590 other => other.clone(),
591 })
592 .collect();
593
594 summary_session.events.push(Event {
595 event_id: event.event_id.clone(),
596 timestamp: event.timestamp,
597 event_type: event.event_type.clone(),
598 task_id: event.task_id.clone(),
599 content: Content {
600 blocks: truncated_blocks,
601 },
602 duration_ms: event.duration_ms,
603 attributes: HashMap::new(), });
605 }
606
607 summary_session.recompute_stats();
609
610 summary_session
611}
612
613fn extract_first_user_text(session: &Session) -> Option<String> {
616 crate::extract::extract_first_user_text(session)
617}
618
619fn extract_objective(session: &Session) -> String {
620 if let Some(user_text) = extract_first_user_text(session).filter(|t| !t.trim().is_empty()) {
621 return truncate_str(&collapse_whitespace(&user_text), 200);
622 }
623
624 if let Some(task_title) = session
625 .events
626 .iter()
627 .find_map(|event| match &event.event_type {
628 EventType::TaskStart { title: Some(title) } => {
629 let title = title.trim();
630 if title.is_empty() {
631 None
632 } else {
633 Some(title.to_string())
634 }
635 }
636 _ => None,
637 })
638 {
639 return truncate_str(&collapse_whitespace(&task_title), 200);
640 }
641
642 if let Some(task_summary) = session
643 .events
644 .iter()
645 .find_map(|event| match &event.event_type {
646 EventType::TaskEnd {
647 summary: Some(summary),
648 } => {
649 let summary = summary.trim();
650 if summary.is_empty() {
651 None
652 } else {
653 Some(summary.to_string())
654 }
655 }
656 _ => None,
657 })
658 {
659 return truncate_str(&collapse_whitespace(&task_summary), 200);
660 }
661
662 if let Some(title) = session.context.title.as_deref().map(str::trim) {
663 if !title.is_empty() {
664 return truncate_str(&collapse_whitespace(title), 200);
665 }
666 }
667
668 "(objective unavailable)".to_string()
669}
670
671fn extract_text_from_event(event: &Event) -> Option<String> {
672 for block in &event.content.blocks {
673 if let ContentBlock::Text { text } = block {
674 let trimmed = text.trim();
675 if !trimmed.is_empty() {
676 return Some(trimmed.to_string());
677 }
678 }
679 }
680 None
681}
682
683fn collapse_whitespace(input: &str) -> String {
684 input.split_whitespace().collect::<Vec<_>>().join(" ")
685}
686
687pub fn format_duration(seconds: u64) -> String {
689 if seconds < 60 {
690 format!("{seconds}s")
691 } else if seconds < 3600 {
692 let m = seconds / 60;
693 let s = seconds % 60;
694 format!("{m}m {s}s")
695 } else {
696 let h = seconds / 3600;
697 let m = (seconds % 3600) / 60;
698 let s = seconds % 60;
699 format!("{h}h {m}m {s}s")
700 }
701}
702
703#[cfg(test)]
706mod tests {
707 use super::*;
708 use crate::{testing, Agent};
709
710 fn make_agent() -> Agent {
711 testing::agent()
712 }
713
714 fn make_event(event_type: EventType, text: &str) -> Event {
715 testing::event(event_type, text)
716 }
717
718 #[test]
719 fn test_format_duration() {
720 assert_eq!(format_duration(0), "0s");
721 assert_eq!(format_duration(45), "45s");
722 assert_eq!(format_duration(90), "1m 30s");
723 assert_eq!(format_duration(750), "12m 30s");
724 assert_eq!(format_duration(3661), "1h 1m 1s");
725 }
726
727 #[test]
728 fn test_handoff_summary_from_session() {
729 let mut session = Session::new("test-id".to_string(), make_agent());
730 session.stats = Stats {
731 event_count: 10,
732 message_count: 3,
733 tool_call_count: 5,
734 duration_seconds: 750,
735 ..Default::default()
736 };
737 session
738 .events
739 .push(make_event(EventType::UserMessage, "Fix the build error"));
740 session
741 .events
742 .push(make_event(EventType::AgentMessage, "I'll fix it now"));
743 session.events.push(make_event(
744 EventType::FileEdit {
745 path: "src/main.rs".to_string(),
746 diff: None,
747 },
748 "",
749 ));
750 session.events.push(make_event(
751 EventType::FileRead {
752 path: "Cargo.toml".to_string(),
753 },
754 "",
755 ));
756 session.events.push(make_event(
757 EventType::ShellCommand {
758 command: "cargo build".to_string(),
759 exit_code: Some(0),
760 },
761 "",
762 ));
763 session.events.push(make_event(
764 EventType::TaskEnd {
765 summary: Some("Build now passes in local env".to_string()),
766 },
767 "",
768 ));
769
770 let summary = HandoffSummary::from_session(&session);
771
772 assert_eq!(summary.source_session_id, "test-id");
773 assert_eq!(summary.objective, "Fix the build error");
774 assert_eq!(summary.files_modified.len(), 1);
775 assert_eq!(summary.files_modified[0].path, "src/main.rs");
776 assert_eq!(summary.files_modified[0].action, "edited");
777 assert_eq!(summary.files_read, vec!["Cargo.toml"]);
778 assert_eq!(summary.shell_commands.len(), 1);
779 assert_eq!(
780 summary.task_summaries,
781 vec!["Build now passes in local env".to_string()]
782 );
783 assert_eq!(summary.key_conversations.len(), 1);
784 assert_eq!(summary.key_conversations[0].user, "Fix the build error");
785 assert_eq!(summary.key_conversations[0].agent, "I'll fix it now");
786 }
787
788 #[test]
789 fn test_handoff_objective_falls_back_to_task_title() {
790 let mut session = Session::new("task-title-fallback".to_string(), make_agent());
791 session.context.title = Some("session-019c-example.jsonl".to_string());
792 session.events.push(make_event(
793 EventType::TaskStart {
794 title: Some("Refactor auth middleware for oauth callback".to_string()),
795 },
796 "",
797 ));
798
799 let summary = HandoffSummary::from_session(&session);
800 assert_eq!(
801 summary.objective,
802 "Refactor auth middleware for oauth callback"
803 );
804 }
805
806 #[test]
807 fn test_handoff_task_summaries_are_deduplicated() {
808 let mut session = Session::new("task-summary-dedupe".to_string(), make_agent());
809 session.events.push(make_event(
810 EventType::TaskEnd {
811 summary: Some("Add worker profile guard".to_string()),
812 },
813 "",
814 ));
815 session.events.push(make_event(
816 EventType::TaskEnd {
817 summary: Some(" ".to_string()),
818 },
819 "",
820 ));
821 session.events.push(make_event(
822 EventType::TaskEnd {
823 summary: Some("Add worker profile guard".to_string()),
824 },
825 "",
826 ));
827 session.events.push(make_event(
828 EventType::TaskEnd {
829 summary: Some("Hide teams nav for worker profile".to_string()),
830 },
831 "",
832 ));
833
834 let summary = HandoffSummary::from_session(&session);
835 assert_eq!(
836 summary.task_summaries,
837 vec![
838 "Add worker profile guard".to_string(),
839 "Hide teams nav for worker profile".to_string()
840 ]
841 );
842 }
843
844 #[test]
845 fn test_files_read_excludes_modified() {
846 let mut session = Session::new("test-id".to_string(), make_agent());
847 session
848 .events
849 .push(make_event(EventType::UserMessage, "test"));
850 session.events.push(make_event(
851 EventType::FileRead {
852 path: "src/main.rs".to_string(),
853 },
854 "",
855 ));
856 session.events.push(make_event(
857 EventType::FileEdit {
858 path: "src/main.rs".to_string(),
859 diff: None,
860 },
861 "",
862 ));
863 session.events.push(make_event(
864 EventType::FileRead {
865 path: "README.md".to_string(),
866 },
867 "",
868 ));
869
870 let summary = HandoffSummary::from_session(&session);
871 assert_eq!(summary.files_read, vec!["README.md"]);
872 assert_eq!(summary.files_modified.len(), 1);
873 }
874
875 #[test]
876 fn test_file_create_not_overwritten_by_edit() {
877 let mut session = Session::new("test-id".to_string(), make_agent());
878 session
879 .events
880 .push(make_event(EventType::UserMessage, "test"));
881 session.events.push(make_event(
882 EventType::FileCreate {
883 path: "new_file.rs".to_string(),
884 },
885 "",
886 ));
887 session.events.push(make_event(
888 EventType::FileEdit {
889 path: "new_file.rs".to_string(),
890 diff: None,
891 },
892 "",
893 ));
894
895 let summary = HandoffSummary::from_session(&session);
896 assert_eq!(summary.files_modified[0].action, "created");
897 }
898
899 #[test]
900 fn test_shell_error_captured() {
901 let mut session = Session::new("test-id".to_string(), make_agent());
902 session
903 .events
904 .push(make_event(EventType::UserMessage, "test"));
905 session.events.push(make_event(
906 EventType::ShellCommand {
907 command: "cargo test".to_string(),
908 exit_code: Some(1),
909 },
910 "",
911 ));
912
913 let summary = HandoffSummary::from_session(&session);
914 assert_eq!(summary.errors.len(), 1);
915 assert!(summary.errors[0].contains("cargo test"));
916 }
917
918 #[test]
919 fn test_generate_handoff_markdown() {
920 let mut session = Session::new("test-id".to_string(), make_agent());
921 session.stats = Stats {
922 event_count: 10,
923 message_count: 3,
924 tool_call_count: 5,
925 duration_seconds: 750,
926 ..Default::default()
927 };
928 session
929 .events
930 .push(make_event(EventType::UserMessage, "Fix the build error"));
931 session
932 .events
933 .push(make_event(EventType::AgentMessage, "I'll fix it now"));
934 session.events.push(make_event(
935 EventType::FileEdit {
936 path: "src/main.rs".to_string(),
937 diff: None,
938 },
939 "",
940 ));
941 session.events.push(make_event(
942 EventType::ShellCommand {
943 command: "cargo build".to_string(),
944 exit_code: Some(0),
945 },
946 "",
947 ));
948 session.events.push(make_event(
949 EventType::TaskEnd {
950 summary: Some("Compile error fixed by updating trait bounds".to_string()),
951 },
952 "",
953 ));
954
955 let summary = HandoffSummary::from_session(&session);
956 let md = generate_handoff_markdown(&summary);
957
958 assert!(md.contains("# Session Handoff"));
959 assert!(md.contains("Fix the build error"));
960 assert!(md.contains("claude-code (claude-opus-4-6)"));
961 assert!(md.contains("12m 30s"));
962 assert!(md.contains("## Task Summaries"));
963 assert!(md.contains("Compile error fixed by updating trait bounds"));
964 assert!(md.contains("`src/main.rs` (edited)"));
965 assert!(md.contains("`cargo build` → 0"));
966 assert!(md.contains("## Key Conversations"));
967 }
968
969 #[test]
970 fn test_merge_summaries() {
971 let mut s1 = Session::new("session-a".to_string(), make_agent());
972 s1.stats.duration_seconds = 100;
973 s1.events.push(make_event(EventType::UserMessage, "task A"));
974 s1.events.push(make_event(
975 EventType::FileEdit {
976 path: "a.rs".to_string(),
977 diff: None,
978 },
979 "",
980 ));
981
982 let mut s2 = Session::new("session-b".to_string(), make_agent());
983 s2.stats.duration_seconds = 200;
984 s2.events.push(make_event(EventType::UserMessage, "task B"));
985 s2.events.push(make_event(
986 EventType::FileEdit {
987 path: "b.rs".to_string(),
988 diff: None,
989 },
990 "",
991 ));
992
993 let sum1 = HandoffSummary::from_session(&s1);
994 let sum2 = HandoffSummary::from_session(&s2);
995 let merged = merge_summaries(&[sum1, sum2]);
996
997 assert_eq!(merged.source_session_ids.len(), 2);
998 assert_eq!(merged.total_duration_seconds, 300);
999 assert_eq!(merged.all_files_modified.len(), 2);
1000 }
1001
1002 #[test]
1003 fn test_generate_handoff_hail() {
1004 let mut session = Session::new("test-id".to_string(), make_agent());
1005 session
1006 .events
1007 .push(make_event(EventType::UserMessage, "Hello"));
1008 session
1009 .events
1010 .push(make_event(EventType::AgentMessage, "Hi there"));
1011 session.events.push(make_event(
1012 EventType::FileRead {
1013 path: "foo.rs".to_string(),
1014 },
1015 "",
1016 ));
1017 session.events.push(make_event(
1018 EventType::FileEdit {
1019 path: "foo.rs".to_string(),
1020 diff: Some("+added line".to_string()),
1021 },
1022 "",
1023 ));
1024 session.events.push(make_event(
1025 EventType::ShellCommand {
1026 command: "cargo build".to_string(),
1027 exit_code: Some(0),
1028 },
1029 "",
1030 ));
1031
1032 let hail = generate_handoff_hail(&session);
1033
1034 assert!(hail.session_id.starts_with("handoff-"));
1035 assert_eq!(hail.context.related_session_ids, vec!["test-id"]);
1036 assert!(hail.context.tags.contains(&"handoff".to_string()));
1037 assert_eq!(hail.events.len(), 3); let jsonl = hail.to_jsonl().unwrap();
1041 let parsed = Session::from_jsonl(&jsonl).unwrap();
1042 assert_eq!(parsed.session_id, hail.session_id);
1043 }
1044}