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 task_summaries: Vec<String>,
49    pub key_conversations: Vec<Conversation>,
50    pub user_messages: Vec<String>,
51}
52
53/// Merged handoff from multiple sessions.
54#[derive(Debug, Clone)]
55pub struct MergedHandoff {
56    pub source_session_ids: Vec<String>,
57    pub summaries: Vec<HandoffSummary>,
58    /// Deduplicated union of all modified files
59    pub all_files_modified: Vec<FileChange>,
60    /// Deduplicated union of all files read (minus modified)
61    pub all_files_read: Vec<String>,
62    pub total_duration_seconds: u64,
63    pub total_errors: Vec<String>,
64}
65
66// ─── Extraction ──────────────────────────────────────────────────────────────
67
68impl HandoffSummary {
69    /// Extract a structured summary from a parsed session.
70    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
101// ─── Functional extractors ──────────────────────────────────────────────────
102
103/// Collect file changes, preserving create/delete precedence over edits.
104fn 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
128/// Collect read-only file paths (excluding those that were also modified).
129fn 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
158/// Collect errors from failed shell commands and tool results.
159fn 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
225/// Pair adjacent User→Agent messages into conversations.
226///
227/// Filters to message events only, then uses `windows(2)` to find
228/// UserMessage→AgentMessage pairs — no mutable tracking state needed.
229fn 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
256// ─── Merge ───────────────────────────────────────────────────────────────────
257
258/// Merge multiple session summaries into a single handoff context.
259pub 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    // Compute sorted_read before consuming all_modified
283    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
309// ─── Markdown generation ─────────────────────────────────────────────────────
310
311/// Generate a Markdown handoff document from a single session summary.
312pub 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    // Objective
319    md.push_str("## Objective\n");
320    md.push_str(&summary.objective);
321    md.push_str("\n\n");
322
323    // Summary
324    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    // Files Modified
359    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    // Files Read
368    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    // Shell Commands
377    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    // Errors
394    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    // Key Conversations (user + agent pairs)
403    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    // User Messages (fallback list)
417    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
428/// Generate a Markdown handoff document from a merged multi-session handoff.
429pub 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    // Per-session summaries
441    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        // Key Conversations for this session
479        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    // Combined files
494    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    // Errors
513    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
524// ─── Summary HAIL generation ─────────────────────────────────────────────────
525
526/// Generate a summary HAIL session from an original session.
527///
528/// Filters events to only include important ones and truncates content.
529pub 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        // Truncate content blocks
573        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(), // strip detailed attributes
604        });
605    }
606
607    // Recompute stats for the filtered events
608    summary_session.recompute_stats();
609
610    summary_session
611}
612
613// ─── Helpers ─────────────────────────────────────────────────────────────────
614
615fn 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
687/// Format seconds into a human-readable duration string.
688pub 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// ─── Tests ───────────────────────────────────────────────────────────────────
704
705#[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        // FileRead and successful ShellCommand should be filtered out
1038        assert_eq!(hail.events.len(), 3); // UserMessage, AgentMessage, FileEdit
1039                                          // Verify HAIL roundtrip
1040        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}