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 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        // Track last user message for pairing
82        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                    // Pair with the preceding user message
135                    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        // Remove read-only files that were also modified
149        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
179// ─── Merge ───────────────────────────────────────────────────────────────────
180
181/// Merge multiple session summaries into a single handoff context.
182pub 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    // Remove read files that were modified
205    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
228// ─── Markdown generation ─────────────────────────────────────────────────────
229
230/// Generate a Markdown handoff document from a single session summary.
231pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String {
232    let mut md = String::new();
233
234    md.push_str("# Session Handoff\n\n");
235
236    // Objective
237    md.push_str("## Objective\n");
238    md.push_str(&summary.objective);
239    md.push_str("\n\n");
240
241    // Summary
242    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    // Files Modified
258    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    // Files Read
267    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    // Shell Commands
276    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    // Errors
293    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    // Key Conversations (user + agent pairs)
302    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    // User Messages (fallback list)
316    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
327/// Generate a Markdown handoff document from a merged multi-session handoff.
328pub 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    // Per-session summaries
339    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        // Key Conversations for this session
358        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    // Combined files
373    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    // Errors
392    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
403// ─── Summary HAIL generation ─────────────────────────────────────────────────
404
405/// Generate a summary HAIL session from an original session.
406///
407/// Filters events to only include important ones and truncates content.
408pub 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        // Truncate content blocks
452        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(), // strip detailed attributes
483        });
484    }
485
486    // Recompute stats for the filtered events
487    summary_session.recompute_stats();
488
489    summary_session
490}
491
492// ─── Helpers ─────────────────────────────────────────────────────────────────
493
494fn 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
510/// Format seconds into a human-readable duration string.
511pub 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// ─── Tests ───────────────────────────────────────────────────────────────────
527
528#[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        // FileRead and successful ShellCommand should be filtered out
801        assert_eq!(hail.events.len(), 3); // UserMessage, AgentMessage, FileEdit
802                                          // Verify HAIL roundtrip
803        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}