Skip to main content

opensession_core/
handoff.rs

1//! Handoff context generation — extract structured summaries from sessions.
2//!
3//! This module provides programmatic extraction of session summaries for handoff
4//! between agent sessions. It supports both single-session and multi-session merge.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::extract::truncate_str;
9use crate::{Content, ContentBlock, Event, EventType, Session, SessionContext, Stats};
10
11// ─── Types ───────────────────────────────────────────────────────────────────
12
13/// A file change observed during a session.
14#[derive(Debug, Clone)]
15pub struct FileChange {
16    pub path: String,
17    /// "created" | "edited" | "deleted"
18    pub action: &'static str,
19}
20
21/// A shell command executed during a session.
22#[derive(Debug, Clone)]
23pub struct ShellCmd {
24    pub command: String,
25    pub exit_code: Option<i32>,
26}
27
28/// A user→agent conversation pair.
29#[derive(Debug, Clone)]
30pub struct Conversation {
31    pub user: String,
32    pub agent: String,
33}
34
35/// Summary extracted from a single session.
36#[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/// Merged handoff from multiple sessions.
53#[derive(Debug, Clone)]
54pub struct MergedHandoff {
55    pub source_session_ids: Vec<String>,
56    pub summaries: Vec<HandoffSummary>,
57    /// Deduplicated union of all modified files
58    pub all_files_modified: Vec<FileChange>,
59    /// Deduplicated union of all files read (minus modified)
60    pub all_files_read: Vec<String>,
61    pub total_duration_seconds: u64,
62    pub total_errors: Vec<String>,
63}
64
65// ─── Extraction ──────────────────────────────────────────────────────────────
66
67impl HandoffSummary {
68    /// Extract a structured summary from a parsed session.
69    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
100// ─── Functional extractors ──────────────────────────────────────────────────
101
102/// Collect file changes, preserving create/delete precedence over edits.
103fn 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
127/// Collect read-only file paths (excluding those that were also modified).
128fn 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
157/// Collect errors from failed shell commands and tool results.
158fn 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
195/// Pair adjacent User→Agent messages into conversations.
196///
197/// Filters to message events only, then uses `windows(2)` to find
198/// UserMessage→AgentMessage pairs — no mutable tracking state needed.
199fn 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
226// ─── Merge ───────────────────────────────────────────────────────────────────
227
228/// Merge multiple session summaries into a single handoff context.
229pub 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    // Compute sorted_read before consuming all_modified
253    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
279// ─── Markdown generation ─────────────────────────────────────────────────────
280
281/// Generate a Markdown handoff document from a single session summary.
282pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String {
283    let mut md = String::new();
284
285    md.push_str("# Session Handoff\n\n");
286
287    // Objective
288    md.push_str("## Objective\n");
289    md.push_str(&summary.objective);
290    md.push_str("\n\n");
291
292    // Summary
293    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    // Files Modified
309    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    // Files Read
318    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    // Shell Commands
327    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    // Errors
344    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    // Key Conversations (user + agent pairs)
353    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    // User Messages (fallback list)
367    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
378/// Generate a Markdown handoff document from a merged multi-session handoff.
379pub 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    // Per-session summaries
390    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        // Key Conversations for this session
409        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    // Combined files
424    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    // Errors
443    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
454// ─── Summary HAIL generation ─────────────────────────────────────────────────
455
456/// Generate a summary HAIL session from an original session.
457///
458/// Filters events to only include important ones and truncates content.
459pub 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        // Truncate content blocks
503        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(), // strip detailed attributes
534        });
535    }
536
537    // Recompute stats for the filtered events
538    summary_session.recompute_stats();
539
540    summary_session
541}
542
543// ─── Helpers ─────────────────────────────────────────────────────────────────
544
545fn 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
561/// Format seconds into a human-readable duration string.
562pub 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// ─── Tests ───────────────────────────────────────────────────────────────────
578
579#[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        // FileRead and successful ShellCommand should be filtered out
838        assert_eq!(hail.events.len(), 3); // UserMessage, AgentMessage, FileEdit
839                                          // Verify HAIL roundtrip
840        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}