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::{BTreeMap, 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, serde::Serialize)]
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, serde::Serialize)]
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, serde::Serialize)]
30pub struct Conversation {
31    pub user: String,
32    pub agent: String,
33}
34
35#[derive(Debug, Clone, serde::Serialize, Default)]
36pub struct ExecutionContract {
37    pub done_definition: Vec<String>,
38    pub next_actions: Vec<String>,
39    pub ordered_commands: Vec<String>,
40    pub rollback_hint: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub rollback_hint_missing_reason: Option<String>,
43}
44
45#[derive(Debug, Clone, serde::Serialize, Default)]
46pub struct Uncertainty {
47    pub assumptions: Vec<String>,
48    pub open_questions: Vec<String>,
49    pub decision_required: Vec<String>,
50}
51
52#[derive(Debug, Clone, serde::Serialize)]
53pub struct CheckRun {
54    pub command: String,
55    pub status: String,
56    pub exit_code: Option<i32>,
57    pub event_id: String,
58}
59
60#[derive(Debug, Clone, serde::Serialize, Default)]
61pub struct Verification {
62    pub checks_run: Vec<CheckRun>,
63    pub checks_passed: Vec<String>,
64    pub checks_failed: Vec<String>,
65    pub required_checks_missing: Vec<String>,
66}
67
68#[derive(Debug, Clone, serde::Serialize)]
69pub struct EvidenceRef {
70    pub id: String,
71    pub claim: String,
72    pub event_id: String,
73    pub timestamp: String,
74    pub source_type: String,
75}
76
77#[derive(Debug, Clone, serde::Serialize)]
78pub struct WorkPackage {
79    pub id: String,
80    pub title: String,
81    pub status: String,
82    pub depends_on: Vec<String>,
83    pub files: Vec<String>,
84    pub commands: Vec<String>,
85    pub evidence_refs: Vec<String>,
86}
87
88/// Summary extracted from a single session.
89#[derive(Debug, Clone, serde::Serialize)]
90pub struct HandoffSummary {
91    pub source_session_id: String,
92    pub objective: String,
93    pub tool: String,
94    pub model: String,
95    pub duration_seconds: u64,
96    pub stats: Stats,
97    pub files_modified: Vec<FileChange>,
98    pub files_read: Vec<String>,
99    pub shell_commands: Vec<ShellCmd>,
100    pub errors: Vec<String>,
101    pub task_summaries: Vec<String>,
102    pub key_conversations: Vec<Conversation>,
103    pub user_messages: Vec<String>,
104    pub execution_contract: ExecutionContract,
105    pub uncertainty: Uncertainty,
106    pub verification: Verification,
107    pub evidence: Vec<EvidenceRef>,
108    pub work_packages: Vec<WorkPackage>,
109}
110
111/// Merged handoff from multiple sessions.
112#[derive(Debug, Clone, serde::Serialize)]
113pub struct MergedHandoff {
114    pub source_session_ids: Vec<String>,
115    pub summaries: Vec<HandoffSummary>,
116    /// Deduplicated union of all modified files
117    pub all_files_modified: Vec<FileChange>,
118    /// Deduplicated union of all files read (minus modified)
119    pub all_files_read: Vec<String>,
120    pub total_duration_seconds: u64,
121    pub total_errors: Vec<String>,
122}
123
124// ─── Extraction ──────────────────────────────────────────────────────────────
125
126impl HandoffSummary {
127    /// Extract a structured summary from a parsed session.
128    pub fn from_session(session: &Session) -> Self {
129        let objective = extract_objective(session);
130
131        let files_modified = collect_file_changes(&session.events);
132        let modified_paths: HashSet<&str> =
133            files_modified.iter().map(|f| f.path.as_str()).collect();
134        let files_read = collect_files_read(&session.events, &modified_paths);
135        let shell_commands = collect_shell_commands(&session.events);
136        let errors = collect_errors(&session.events);
137        let task_summaries = collect_task_summaries(&session.events);
138        let user_messages = collect_user_messages(&session.events);
139        let key_conversations = collect_conversation_pairs(&session.events);
140        let verification = collect_verification(&session.events);
141        let uncertainty = collect_uncertainty(session, &verification);
142        let execution_contract = build_execution_contract(
143            &task_summaries,
144            &verification,
145            &uncertainty,
146            &shell_commands,
147        );
148        let evidence = collect_evidence(session, &objective, &task_summaries, &uncertainty);
149        let work_packages = build_work_packages(&session.events, &evidence);
150
151        HandoffSummary {
152            source_session_id: session.session_id.clone(),
153            objective,
154            tool: session.agent.tool.clone(),
155            model: session.agent.model.clone(),
156            duration_seconds: session.stats.duration_seconds,
157            stats: session.stats.clone(),
158            files_modified,
159            files_read,
160            shell_commands,
161            errors,
162            task_summaries,
163            key_conversations,
164            user_messages,
165            execution_contract,
166            uncertainty,
167            verification,
168            evidence,
169            work_packages,
170        }
171    }
172}
173
174// ─── Functional extractors ──────────────────────────────────────────────────
175
176/// Collect file changes, preserving create/delete precedence over edits.
177fn collect_file_changes(events: &[Event]) -> Vec<FileChange> {
178    let map = events.iter().fold(HashMap::new(), |mut map, event| {
179        match &event.event_type {
180            EventType::FileCreate { path } => {
181                map.insert(path.clone(), "created");
182            }
183            EventType::FileEdit { path, .. } => {
184                map.entry(path.clone()).or_insert("edited");
185            }
186            EventType::FileDelete { path } => {
187                map.insert(path.clone(), "deleted");
188            }
189            _ => {}
190        }
191        map
192    });
193    let mut result: Vec<FileChange> = map
194        .into_iter()
195        .map(|(path, action)| FileChange { path, action })
196        .collect();
197    result.sort_by(|a, b| a.path.cmp(&b.path));
198    result
199}
200
201/// Collect read-only file paths (excluding those that were also modified).
202fn collect_files_read(events: &[Event], modified_paths: &HashSet<&str>) -> Vec<String> {
203    let mut read: Vec<String> = events
204        .iter()
205        .filter_map(|e| match &e.event_type {
206            EventType::FileRead { path } if !modified_paths.contains(path.as_str()) => {
207                Some(path.clone())
208            }
209            _ => None,
210        })
211        .collect::<HashSet<_>>()
212        .into_iter()
213        .collect();
214    read.sort();
215    read
216}
217
218fn collect_shell_commands(events: &[Event]) -> Vec<ShellCmd> {
219    events
220        .iter()
221        .filter_map(|event| match &event.event_type {
222            EventType::ShellCommand { command, exit_code } => Some(ShellCmd {
223                command: command.clone(),
224                exit_code: *exit_code,
225            }),
226            _ => None,
227        })
228        .collect()
229}
230
231/// Collect errors from failed shell commands and tool results.
232fn collect_errors(events: &[Event]) -> Vec<String> {
233    events
234        .iter()
235        .filter_map(|event| match &event.event_type {
236            EventType::ShellCommand { command, exit_code }
237                if *exit_code != Some(0) && exit_code.is_some() =>
238            {
239                Some(format!(
240                    "Shell: `{}` → exit {}",
241                    truncate_str(command, 80),
242                    exit_code.unwrap()
243                ))
244            }
245            EventType::ToolResult {
246                is_error: true,
247                name,
248                ..
249            } => {
250                let detail = extract_text_from_event(event);
251                Some(match detail {
252                    Some(d) => format!("Tool error: {} — {}", name, truncate_str(&d, 80)),
253                    None => format!("Tool error: {name}"),
254                })
255            }
256            _ => None,
257        })
258        .collect()
259}
260
261fn collect_verification(events: &[Event]) -> Verification {
262    let mut tool_result_by_call: HashMap<String, (&Event, bool)> = HashMap::new();
263    for event in events {
264        if let EventType::ToolResult { is_error, .. } = &event.event_type {
265            if let Some(call_id) = event.semantic_call_id() {
266                tool_result_by_call
267                    .entry(call_id.to_string())
268                    .or_insert((event, *is_error));
269            }
270        }
271    }
272
273    let mut checks_run = Vec::new();
274    for event in events {
275        let EventType::ShellCommand { command, exit_code } = &event.event_type else {
276            continue;
277        };
278
279        let (status, resolved_exit_code) = match exit_code {
280            Some(0) => ("passed".to_string(), Some(0)),
281            Some(code) => ("failed".to_string(), Some(*code)),
282            None => {
283                if let Some(call_id) = event.semantic_call_id() {
284                    if let Some((_, is_error)) = tool_result_by_call.get(call_id) {
285                        if *is_error {
286                            ("failed".to_string(), None)
287                        } else {
288                            ("passed".to_string(), None)
289                        }
290                    } else {
291                        ("unknown".to_string(), None)
292                    }
293                } else {
294                    ("unknown".to_string(), None)
295                }
296            }
297        };
298
299        checks_run.push(CheckRun {
300            command: collapse_whitespace(command),
301            status,
302            exit_code: resolved_exit_code,
303            event_id: event.event_id.clone(),
304        });
305    }
306
307    let mut checks_passed: Vec<String> = checks_run
308        .iter()
309        .filter(|run| run.status == "passed")
310        .map(|run| run.command.clone())
311        .collect();
312    let mut checks_failed: Vec<String> = checks_run
313        .iter()
314        .filter(|run| run.status == "failed")
315        .map(|run| run.command.clone())
316        .collect();
317
318    dedupe_keep_order(&mut checks_passed);
319    dedupe_keep_order(&mut checks_failed);
320
321    let unresolved_failed = unresolved_failed_commands(&checks_run);
322    let mut required_checks_missing = unresolved_failed
323        .iter()
324        .map(|cmd| format!("Unresolved failed check: `{cmd}`"))
325        .collect::<Vec<_>>();
326
327    let has_modified_files = events.iter().any(|event| {
328        matches!(
329            event.event_type,
330            EventType::FileEdit { .. }
331                | EventType::FileCreate { .. }
332                | EventType::FileDelete { .. }
333        )
334    });
335    if has_modified_files && checks_run.is_empty() {
336        required_checks_missing
337            .push("No verification command found after file modifications.".to_string());
338    }
339
340    Verification {
341        checks_run,
342        checks_passed,
343        checks_failed,
344        required_checks_missing,
345    }
346}
347
348fn collect_uncertainty(session: &Session, verification: &Verification) -> Uncertainty {
349    let mut assumptions = Vec::new();
350    if extract_objective(session) == "(objective unavailable)" {
351        assumptions.push(
352            "Objective inferred as unavailable; downstream agent must restate objective."
353                .to_string(),
354        );
355    }
356
357    let open_questions = collect_open_questions(&session.events);
358
359    let mut decision_required = Vec::new();
360    for event in &session.events {
361        if let EventType::Custom { kind } = &event.event_type {
362            if kind == "turn_aborted" {
363                let reason = event
364                    .attr_str("reason")
365                    .map(String::from)
366                    .unwrap_or_else(|| "turn aborted".to_string());
367                decision_required.push(format!("Turn aborted: {reason}"));
368            }
369        }
370    }
371    for missing in &verification.required_checks_missing {
372        decision_required.push(missing.clone());
373    }
374    for question in &open_questions {
375        decision_required.push(format!("Resolve open question: {question}"));
376    }
377
378    dedupe_keep_order(&mut assumptions);
379    let mut open_questions = open_questions;
380    dedupe_keep_order(&mut open_questions);
381    dedupe_keep_order(&mut decision_required);
382
383    Uncertainty {
384        assumptions,
385        open_questions,
386        decision_required,
387    }
388}
389
390fn build_execution_contract(
391    task_summaries: &[String],
392    verification: &Verification,
393    uncertainty: &Uncertainty,
394    shell_commands: &[ShellCmd],
395) -> ExecutionContract {
396    let done_definition = task_summaries.to_vec();
397
398    let mut next_actions = unresolved_failed_commands(&verification.checks_run)
399        .into_iter()
400        .map(|cmd| format!("Fix and re-run `{cmd}` until the check passes."))
401        .collect::<Vec<_>>();
402    next_actions.extend(
403        uncertainty
404            .open_questions
405            .iter()
406            .map(|q| format!("Resolve open question: {q}")),
407    );
408
409    if done_definition.is_empty() && next_actions.is_empty() {
410        next_actions.push(
411            "Define completion criteria and run at least one verification command.".to_string(),
412        );
413    }
414    dedupe_keep_order(&mut next_actions);
415
416    let unresolved = unresolved_failed_commands(&verification.checks_run);
417    let mut ordered_commands = unresolved;
418    for cmd in shell_commands
419        .iter()
420        .map(|c| collapse_whitespace(&c.command))
421    {
422        if !ordered_commands.iter().any(|existing| existing == &cmd) {
423            ordered_commands.push(cmd);
424        }
425    }
426
427    let has_git_commit = shell_commands
428        .iter()
429        .any(|cmd| cmd.command.to_ascii_lowercase().contains("git commit"));
430    let (rollback_hint, rollback_hint_missing_reason) = if has_git_commit {
431        (
432            Some(
433                "Use `git revert <commit>` for committed changes, then re-run verification."
434                    .to_string(),
435            ),
436            None,
437        )
438    } else {
439        (
440            None,
441            Some("No committed change signal found in events.".to_string()),
442        )
443    };
444
445    ExecutionContract {
446        done_definition,
447        next_actions,
448        ordered_commands,
449        rollback_hint,
450        rollback_hint_missing_reason,
451    }
452}
453
454fn collect_evidence(
455    session: &Session,
456    objective: &str,
457    task_summaries: &[String],
458    uncertainty: &Uncertainty,
459) -> Vec<EvidenceRef> {
460    let mut evidence = Vec::new();
461    let mut next_id = 1usize;
462
463    if let Some(event) = find_objective_event(session) {
464        evidence.push(EvidenceRef {
465            id: format!("evidence-{next_id}"),
466            claim: format!("objective: {objective}"),
467            event_id: event.event_id.clone(),
468            timestamp: event.timestamp.to_rfc3339(),
469            source_type: event_source_type(event),
470        });
471        next_id += 1;
472    }
473
474    for summary in task_summaries {
475        if let Some(event) = find_task_summary_event(&session.events, summary) {
476            evidence.push(EvidenceRef {
477                id: format!("evidence-{next_id}"),
478                claim: format!("task_done: {summary}"),
479                event_id: event.event_id.clone(),
480                timestamp: event.timestamp.to_rfc3339(),
481                source_type: event_source_type(event),
482            });
483            next_id += 1;
484        }
485    }
486
487    for decision in &uncertainty.decision_required {
488        if let Some(event) = find_decision_event(&session.events, decision) {
489            evidence.push(EvidenceRef {
490                id: format!("evidence-{next_id}"),
491                claim: format!("decision_required: {decision}"),
492                event_id: event.event_id.clone(),
493                timestamp: event.timestamp.to_rfc3339(),
494                source_type: event_source_type(event),
495            });
496            next_id += 1;
497        }
498    }
499
500    evidence
501}
502
503fn build_work_packages(events: &[Event], evidence: &[EvidenceRef]) -> Vec<WorkPackage> {
504    #[derive(Default)]
505    struct WorkPackageAcc {
506        title: Option<String>,
507        status: String,
508        first_ts: Option<chrono::DateTime<chrono::Utc>>,
509        files: HashSet<String>,
510        commands: Vec<String>,
511        evidence_refs: Vec<String>,
512    }
513
514    let mut evidence_by_event: HashMap<&str, Vec<String>> = HashMap::new();
515    for ev in evidence {
516        evidence_by_event
517            .entry(ev.event_id.as_str())
518            .or_default()
519            .push(ev.id.clone());
520    }
521
522    let mut grouped: BTreeMap<String, WorkPackageAcc> = BTreeMap::new();
523    for event in events {
524        let key = package_key_for_event(event);
525        let acc = grouped
526            .entry(key.clone())
527            .or_insert_with(|| WorkPackageAcc {
528                status: "pending".to_string(),
529                ..Default::default()
530            });
531
532        if acc.first_ts.is_none() {
533            acc.first_ts = Some(event.timestamp);
534        }
535        if let Some(ids) = evidence_by_event.get(event.event_id.as_str()) {
536            acc.evidence_refs.extend(ids.clone());
537        }
538
539        match &event.event_type {
540            EventType::TaskStart { title } => {
541                if let Some(title) = title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
542                    acc.title = Some(title.to_string());
543                }
544                if acc.status != "completed" {
545                    acc.status = "in_progress".to_string();
546                }
547            }
548            EventType::TaskEnd { .. } => {
549                acc.status = "completed".to_string();
550            }
551            EventType::FileEdit { path, .. }
552            | EventType::FileCreate { path }
553            | EventType::FileDelete { path } => {
554                acc.files.insert(path.clone());
555            }
556            EventType::ShellCommand { command, .. } => {
557                acc.commands.push(collapse_whitespace(command));
558            }
559            _ => {}
560        }
561    }
562
563    let mut packages = grouped
564        .into_iter()
565        .map(|(id, mut acc)| {
566            dedupe_keep_order(&mut acc.commands);
567            dedupe_keep_order(&mut acc.evidence_refs);
568            let mut files: Vec<String> = acc.files.into_iter().collect();
569            files.sort();
570            WorkPackage {
571                title: acc.title.unwrap_or_else(|| {
572                    if id == "main" {
573                        "Main flow".to_string()
574                    } else {
575                        format!("Task {id}")
576                    }
577                }),
578                id,
579                status: acc.status,
580                depends_on: Vec::new(),
581                files,
582                commands: acc.commands,
583                evidence_refs: acc.evidence_refs,
584            }
585        })
586        .collect::<Vec<_>>();
587
588    packages.sort_by(|a, b| a.id.cmp(&b.id));
589
590    for i in 0..packages.len() {
591        let cur_files: HashSet<&str> = packages[i].files.iter().map(String::as_str).collect();
592        if cur_files.is_empty() {
593            continue;
594        }
595        let mut dependency: Option<String> = None;
596        for j in (0..i).rev() {
597            let prev_files: HashSet<&str> = packages[j].files.iter().map(String::as_str).collect();
598            if !prev_files.is_empty() && !cur_files.is_disjoint(&prev_files) {
599                dependency = Some(packages[j].id.clone());
600                break;
601            }
602        }
603        if let Some(dep) = dependency {
604            packages[i].depends_on.push(dep);
605        }
606    }
607
608    packages
609}
610
611fn package_key_for_event(event: &Event) -> String {
612    if let Some(task_id) = event
613        .task_id
614        .as_deref()
615        .map(str::trim)
616        .filter(|id| !id.is_empty())
617    {
618        return task_id.to_string();
619    }
620    if let Some(group_id) = event.semantic_group_id() {
621        return group_id.to_string();
622    }
623    "main".to_string()
624}
625
626fn find_objective_event(session: &Session) -> Option<&Event> {
627    session
628        .events
629        .iter()
630        .find(|event| matches!(event.event_type, EventType::UserMessage))
631        .or_else(|| {
632            session.events.iter().find(|event| {
633                matches!(
634                    event.event_type,
635                    EventType::TaskStart { .. } | EventType::TaskEnd { .. }
636                )
637            })
638        })
639}
640
641fn find_task_summary_event<'a>(events: &'a [Event], summary: &str) -> Option<&'a Event> {
642    let normalized_target = collapse_whitespace(summary);
643    events.iter().find(|event| {
644        let EventType::TaskEnd {
645            summary: Some(candidate),
646        } = &event.event_type
647        else {
648            return false;
649        };
650        collapse_whitespace(candidate) == normalized_target
651    })
652}
653
654fn find_decision_event<'a>(events: &'a [Event], decision: &str) -> Option<&'a Event> {
655    if decision.to_ascii_lowercase().contains("turn aborted") {
656        return events.iter().find(|event| {
657            matches!(
658                &event.event_type,
659                EventType::Custom { kind } if kind == "turn_aborted"
660            )
661        });
662    }
663    if decision.to_ascii_lowercase().contains("open question") {
664        return events
665            .iter()
666            .find(|event| event.attr_str("source") == Some("interactive_question"));
667    }
668    None
669}
670
671fn collect_open_questions(events: &[Event]) -> Vec<String> {
672    let mut question_meta: BTreeMap<String, String> = BTreeMap::new();
673    let mut asked_order = Vec::new();
674    let mut answered_ids = HashSet::new();
675
676    for event in events {
677        if event.attr_str("source") == Some("interactive_question") {
678            if let Some(items) = event
679                .attributes
680                .get("question_meta")
681                .and_then(|v| v.as_array())
682            {
683                for item in items {
684                    let Some(id) = item
685                        .get("id")
686                        .and_then(|v| v.as_str())
687                        .map(str::trim)
688                        .filter(|v| !v.is_empty())
689                    else {
690                        continue;
691                    };
692                    let text = item
693                        .get("question")
694                        .or_else(|| item.get("header"))
695                        .and_then(|v| v.as_str())
696                        .map(str::trim)
697                        .filter(|v| !v.is_empty())
698                        .unwrap_or(id);
699                    if !question_meta.contains_key(id) {
700                        asked_order.push(id.to_string());
701                    }
702                    question_meta.insert(id.to_string(), text.to_string());
703                }
704            } else if let Some(ids) = event
705                .attributes
706                .get("question_ids")
707                .and_then(|v| v.as_array())
708                .map(|arr| {
709                    arr.iter()
710                        .filter_map(|v| v.as_str())
711                        .map(str::trim)
712                        .filter(|v| !v.is_empty())
713                        .map(String::from)
714                        .collect::<Vec<_>>()
715                })
716            {
717                for id in ids {
718                    if !question_meta.contains_key(&id) {
719                        asked_order.push(id.clone());
720                    }
721                    question_meta.entry(id.clone()).or_insert(id);
722                }
723            }
724        }
725
726        if event.attr_str("source") == Some("interactive") {
727            if let Some(ids) = event
728                .attributes
729                .get("question_ids")
730                .and_then(|v| v.as_array())
731            {
732                for id in ids
733                    .iter()
734                    .filter_map(|v| v.as_str())
735                    .map(str::trim)
736                    .filter(|v| !v.is_empty())
737                {
738                    answered_ids.insert(id.to_string());
739                }
740            }
741        }
742    }
743
744    asked_order
745        .into_iter()
746        .filter(|id| !answered_ids.contains(id))
747        .map(|id| {
748            let text = question_meta
749                .get(&id)
750                .cloned()
751                .unwrap_or_else(|| id.clone());
752            format!("{id}: {text}")
753        })
754        .collect()
755}
756
757fn unresolved_failed_commands(checks_run: &[CheckRun]) -> Vec<String> {
758    let mut unresolved = Vec::new();
759    for (idx, run) in checks_run.iter().enumerate() {
760        if run.status != "failed" {
761            continue;
762        }
763        let resolved = checks_run
764            .iter()
765            .skip(idx + 1)
766            .any(|later| later.command == run.command && later.status == "passed");
767        if !resolved {
768            unresolved.push(run.command.clone());
769        }
770    }
771    dedupe_keep_order(&mut unresolved);
772    unresolved
773}
774
775fn dedupe_keep_order(values: &mut Vec<String>) {
776    let mut seen = HashSet::new();
777    values.retain(|value| seen.insert(value.clone()));
778}
779
780fn event_source_type(event: &Event) -> String {
781    event
782        .source_raw_type()
783        .map(String::from)
784        .unwrap_or_else(|| match &event.event_type {
785            EventType::UserMessage => "UserMessage".to_string(),
786            EventType::AgentMessage => "AgentMessage".to_string(),
787            EventType::SystemMessage => "SystemMessage".to_string(),
788            EventType::Thinking => "Thinking".to_string(),
789            EventType::ToolCall { .. } => "ToolCall".to_string(),
790            EventType::ToolResult { .. } => "ToolResult".to_string(),
791            EventType::FileRead { .. } => "FileRead".to_string(),
792            EventType::CodeSearch { .. } => "CodeSearch".to_string(),
793            EventType::FileSearch { .. } => "FileSearch".to_string(),
794            EventType::FileEdit { .. } => "FileEdit".to_string(),
795            EventType::FileCreate { .. } => "FileCreate".to_string(),
796            EventType::FileDelete { .. } => "FileDelete".to_string(),
797            EventType::ShellCommand { .. } => "ShellCommand".to_string(),
798            EventType::ImageGenerate { .. } => "ImageGenerate".to_string(),
799            EventType::VideoGenerate { .. } => "VideoGenerate".to_string(),
800            EventType::AudioGenerate { .. } => "AudioGenerate".to_string(),
801            EventType::WebSearch { .. } => "WebSearch".to_string(),
802            EventType::WebFetch { .. } => "WebFetch".to_string(),
803            EventType::TaskStart { .. } => "TaskStart".to_string(),
804            EventType::TaskEnd { .. } => "TaskEnd".to_string(),
805            EventType::Custom { kind } => format!("Custom:{kind}"),
806        })
807}
808
809fn collect_task_summaries(events: &[Event]) -> Vec<String> {
810    let mut seen = HashSet::new();
811    let mut summaries = Vec::new();
812
813    for event in events {
814        let EventType::TaskEnd {
815            summary: Some(summary),
816        } = &event.event_type
817        else {
818            continue;
819        };
820
821        let summary = summary.trim();
822        if summary.is_empty() {
823            continue;
824        }
825
826        let normalized = collapse_whitespace(summary);
827        if normalized.eq_ignore_ascii_case("synthetic end (missing task_complete)") {
828            continue;
829        }
830        if seen.insert(normalized.clone()) {
831            summaries.push(truncate_str(&normalized, 180));
832        }
833    }
834
835    summaries
836}
837
838fn collect_user_messages(events: &[Event]) -> Vec<String> {
839    events
840        .iter()
841        .filter(|e| matches!(&e.event_type, EventType::UserMessage))
842        .filter_map(extract_text_from_event)
843        .collect()
844}
845
846/// Pair adjacent User→Agent messages into conversations.
847///
848/// Filters to message events only, then uses `windows(2)` to find
849/// UserMessage→AgentMessage pairs — no mutable tracking state needed.
850fn collect_conversation_pairs(events: &[Event]) -> Vec<Conversation> {
851    let messages: Vec<&Event> = events
852        .iter()
853        .filter(|e| {
854            matches!(
855                &e.event_type,
856                EventType::UserMessage | EventType::AgentMessage
857            )
858        })
859        .collect();
860
861    messages
862        .windows(2)
863        .filter_map(|pair| match (&pair[0].event_type, &pair[1].event_type) {
864            (EventType::UserMessage, EventType::AgentMessage) => {
865                let user_text = extract_text_from_event(pair[0])?;
866                let agent_text = extract_text_from_event(pair[1])?;
867                Some(Conversation {
868                    user: truncate_str(&user_text, 300),
869                    agent: truncate_str(&agent_text, 300),
870                })
871            }
872            _ => None,
873        })
874        .collect()
875}
876
877// ─── Merge ───────────────────────────────────────────────────────────────────
878
879/// Merge multiple session summaries into a single handoff context.
880pub fn merge_summaries(summaries: &[HandoffSummary]) -> MergedHandoff {
881    let session_ids: Vec<String> = summaries
882        .iter()
883        .map(|s| s.source_session_id.clone())
884        .collect();
885    let total_duration: u64 = summaries.iter().map(|s| s.duration_seconds).sum();
886    let total_errors: Vec<String> = summaries
887        .iter()
888        .flat_map(|s| {
889            s.errors
890                .iter()
891                .map(move |err| format!("[{}] {}", s.source_session_id, err))
892        })
893        .collect();
894
895    let all_modified: HashMap<String, &str> = summaries
896        .iter()
897        .flat_map(|s| &s.files_modified)
898        .fold(HashMap::new(), |mut map, fc| {
899            map.entry(fc.path.clone()).or_insert(fc.action);
900            map
901        });
902
903    // Compute sorted_read before consuming all_modified
904    let mut sorted_read: Vec<String> = summaries
905        .iter()
906        .flat_map(|s| &s.files_read)
907        .filter(|p| !all_modified.contains_key(p.as_str()))
908        .cloned()
909        .collect::<HashSet<_>>()
910        .into_iter()
911        .collect();
912    sorted_read.sort();
913
914    let mut sorted_modified: Vec<FileChange> = all_modified
915        .into_iter()
916        .map(|(path, action)| FileChange { path, action })
917        .collect();
918    sorted_modified.sort_by(|a, b| a.path.cmp(&b.path));
919
920    MergedHandoff {
921        source_session_ids: session_ids,
922        summaries: summaries.to_vec(),
923        all_files_modified: sorted_modified,
924        all_files_read: sorted_read,
925        total_duration_seconds: total_duration,
926        total_errors,
927    }
928}
929
930#[derive(Debug, Clone, serde::Serialize)]
931pub struct ValidationFinding {
932    pub code: String,
933    pub severity: String,
934    pub message: String,
935}
936
937#[derive(Debug, Clone, serde::Serialize)]
938pub struct HandoffValidationReport {
939    pub session_id: String,
940    pub passed: bool,
941    pub findings: Vec<ValidationFinding>,
942}
943
944pub fn validate_handoff_summary(summary: &HandoffSummary) -> HandoffValidationReport {
945    let mut findings = Vec::new();
946
947    if summary.objective.trim().is_empty() || summary.objective == "(objective unavailable)" {
948        findings.push(ValidationFinding {
949            code: "objective_missing".to_string(),
950            severity: "warning".to_string(),
951            message: "Objective is unavailable.".to_string(),
952        });
953    }
954
955    let unresolved_failures = unresolved_failed_commands(&summary.verification.checks_run);
956    if !unresolved_failures.is_empty() && summary.execution_contract.next_actions.is_empty() {
957        findings.push(ValidationFinding {
958            code: "next_actions_missing".to_string(),
959            severity: "warning".to_string(),
960            message: "Unresolved failed checks exist but no next action was generated.".to_string(),
961        });
962    }
963
964    if !summary.files_modified.is_empty() && summary.verification.checks_run.is_empty() {
965        findings.push(ValidationFinding {
966            code: "verification_missing".to_string(),
967            severity: "warning".to_string(),
968            message: "Files were modified but no verification check was recorded.".to_string(),
969        });
970    }
971
972    if summary.evidence.is_empty() {
973        findings.push(ValidationFinding {
974            code: "evidence_missing".to_string(),
975            severity: "warning".to_string(),
976            message: "No evidence references were generated.".to_string(),
977        });
978    } else if !summary
979        .evidence
980        .iter()
981        .any(|ev| ev.claim.starts_with("objective:"))
982    {
983        findings.push(ValidationFinding {
984            code: "objective_evidence_missing".to_string(),
985            severity: "warning".to_string(),
986            message: "Objective exists but objective evidence is missing.".to_string(),
987        });
988    }
989
990    if has_work_package_cycle(&summary.work_packages) {
991        findings.push(ValidationFinding {
992            code: "work_package_cycle".to_string(),
993            severity: "error".to_string(),
994            message: "work_packages.depends_on contains a cycle.".to_string(),
995        });
996    }
997
998    HandoffValidationReport {
999        session_id: summary.source_session_id.clone(),
1000        passed: findings.is_empty(),
1001        findings,
1002    }
1003}
1004
1005pub fn validate_handoff_summaries(summaries: &[HandoffSummary]) -> Vec<HandoffValidationReport> {
1006    summaries.iter().map(validate_handoff_summary).collect()
1007}
1008
1009fn has_work_package_cycle(packages: &[WorkPackage]) -> bool {
1010    let mut state: HashMap<&str, u8> = HashMap::new();
1011    let deps: HashMap<&str, Vec<&str>> = packages
1012        .iter()
1013        .map(|wp| {
1014            (
1015                wp.id.as_str(),
1016                wp.depends_on.iter().map(String::as_str).collect::<Vec<_>>(),
1017            )
1018        })
1019        .collect();
1020
1021    fn dfs<'a>(
1022        node: &'a str,
1023        state: &mut HashMap<&'a str, u8>,
1024        deps: &HashMap<&'a str, Vec<&'a str>>,
1025    ) -> bool {
1026        match state.get(node).copied() {
1027            Some(1) => return true,
1028            Some(2) => return false,
1029            _ => {}
1030        }
1031        state.insert(node, 1);
1032        if let Some(children) = deps.get(node) {
1033            for child in children {
1034                if !deps.contains_key(child) {
1035                    continue;
1036                }
1037                if dfs(child, state, deps) {
1038                    return true;
1039                }
1040            }
1041        }
1042        state.insert(node, 2);
1043        false
1044    }
1045
1046    for node in deps.keys().copied() {
1047        if dfs(node, &mut state, &deps) {
1048            return true;
1049        }
1050    }
1051    false
1052}
1053
1054// ─── Markdown generation ─────────────────────────────────────────────────────
1055
1056/// Generate a v2 Markdown handoff document from a single session summary.
1057pub fn generate_handoff_markdown_v2(summary: &HandoffSummary) -> String {
1058    let mut md = String::new();
1059    md.push_str("# Session Handoff\n\n");
1060    append_v2_markdown_sections(&mut md, summary);
1061    md
1062}
1063
1064/// Generate a v2 Markdown handoff document from merged summaries.
1065pub fn generate_merged_handoff_markdown_v2(merged: &MergedHandoff) -> String {
1066    let mut md = String::new();
1067    md.push_str("# Merged Session Handoff\n\n");
1068    md.push_str(&format!(
1069        "**Sessions:** {} | **Total Duration:** {}\n\n",
1070        merged.source_session_ids.len(),
1071        format_duration(merged.total_duration_seconds)
1072    ));
1073
1074    for (idx, summary) in merged.summaries.iter().enumerate() {
1075        md.push_str(&format!(
1076            "---\n\n## Session {} — {}\n\n",
1077            idx + 1,
1078            summary.source_session_id
1079        ));
1080        append_v2_markdown_sections(&mut md, summary);
1081        md.push('\n');
1082    }
1083
1084    md
1085}
1086
1087fn append_v2_markdown_sections(md: &mut String, summary: &HandoffSummary) {
1088    md.push_str("## Objective\n");
1089    md.push_str(&summary.objective);
1090    md.push_str("\n\n");
1091
1092    md.push_str("## Current State\n");
1093    md.push_str(&format!(
1094        "- **Tool:** {} ({})\n- **Duration:** {}\n- **Messages:** {} | Tool calls: {} | Events: {}\n",
1095        summary.tool,
1096        summary.model,
1097        format_duration(summary.duration_seconds),
1098        summary.stats.message_count,
1099        summary.stats.tool_call_count,
1100        summary.stats.event_count
1101    ));
1102    if !summary.execution_contract.done_definition.is_empty() {
1103        md.push_str("- **Done:**\n");
1104        for done in &summary.execution_contract.done_definition {
1105            md.push_str(&format!("  - {done}\n"));
1106        }
1107    }
1108    md.push('\n');
1109
1110    md.push_str("## Next Actions (ordered)\n");
1111    if summary.execution_contract.next_actions.is_empty() {
1112        md.push_str("_(none)_\n");
1113    } else {
1114        for (idx, action) in summary.execution_contract.next_actions.iter().enumerate() {
1115            md.push_str(&format!("{}. {}\n", idx + 1, action));
1116        }
1117    }
1118    md.push('\n');
1119
1120    md.push_str("## Verification\n");
1121    if summary.verification.checks_run.is_empty() {
1122        md.push_str("- checks_run: _(none)_\n");
1123    } else {
1124        for check in &summary.verification.checks_run {
1125            let code = check
1126                .exit_code
1127                .map(|c| c.to_string())
1128                .unwrap_or_else(|| "?".to_string());
1129            md.push_str(&format!(
1130                "- [{}] `{}` (exit: {}, event: {})\n",
1131                check.status, check.command, code, check.event_id
1132            ));
1133        }
1134    }
1135    if !summary.verification.required_checks_missing.is_empty() {
1136        md.push_str("- required_checks_missing:\n");
1137        for item in &summary.verification.required_checks_missing {
1138            md.push_str(&format!("  - {item}\n"));
1139        }
1140    }
1141    md.push('\n');
1142
1143    md.push_str("## Blockers / Decisions\n");
1144    if summary.uncertainty.decision_required.is_empty()
1145        && summary.uncertainty.open_questions.is_empty()
1146    {
1147        md.push_str("_(none)_\n");
1148    } else {
1149        for item in &summary.uncertainty.decision_required {
1150            md.push_str(&format!("- {item}\n"));
1151        }
1152        if !summary.uncertainty.open_questions.is_empty() {
1153            md.push_str("- open_questions:\n");
1154            for item in &summary.uncertainty.open_questions {
1155                md.push_str(&format!("  - {item}\n"));
1156            }
1157        }
1158    }
1159    md.push('\n');
1160
1161    md.push_str("## Evidence Index\n");
1162    if summary.evidence.is_empty() {
1163        md.push_str("_(none)_\n");
1164    } else {
1165        for ev in &summary.evidence {
1166            md.push_str(&format!(
1167                "- `{}` {} ({}, {}, {})\n",
1168                ev.id, ev.claim, ev.event_id, ev.source_type, ev.timestamp
1169            ));
1170        }
1171    }
1172    md.push('\n');
1173
1174    md.push_str("## Conversations\n");
1175    if summary.key_conversations.is_empty() {
1176        md.push_str("_(none)_\n");
1177    } else {
1178        for (idx, conv) in summary.key_conversations.iter().enumerate() {
1179            md.push_str(&format!(
1180                "### {}. User\n{}\n\n### {}. Agent\n{}\n\n",
1181                idx + 1,
1182                truncate_str(&conv.user, 300),
1183                idx + 1,
1184                truncate_str(&conv.agent, 300)
1185            ));
1186        }
1187    }
1188
1189    md.push_str("## User Messages\n");
1190    if summary.user_messages.is_empty() {
1191        md.push_str("_(none)_\n");
1192    } else {
1193        for (idx, msg) in summary.user_messages.iter().enumerate() {
1194            md.push_str(&format!("{}. {}\n", idx + 1, truncate_str(msg, 150)));
1195        }
1196    }
1197}
1198
1199/// Generate a Markdown handoff document from a single session summary.
1200pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String {
1201    const MAX_TASK_SUMMARIES_DISPLAY: usize = 5;
1202    let mut md = String::new();
1203
1204    md.push_str("# Session Handoff\n\n");
1205
1206    // Objective
1207    md.push_str("## Objective\n");
1208    md.push_str(&summary.objective);
1209    md.push_str("\n\n");
1210
1211    // Summary
1212    md.push_str("## Summary\n");
1213    md.push_str(&format!(
1214        "- **Tool:** {} ({})\n",
1215        summary.tool, summary.model
1216    ));
1217    md.push_str(&format!(
1218        "- **Duration:** {}\n",
1219        format_duration(summary.duration_seconds)
1220    ));
1221    md.push_str(&format!(
1222        "- **Messages:** {} | Tool calls: {} | Events: {}\n",
1223        summary.stats.message_count, summary.stats.tool_call_count, summary.stats.event_count
1224    ));
1225    md.push('\n');
1226
1227    if !summary.task_summaries.is_empty() {
1228        md.push_str("## Task Summaries\n");
1229        for (idx, task_summary) in summary
1230            .task_summaries
1231            .iter()
1232            .take(MAX_TASK_SUMMARIES_DISPLAY)
1233            .enumerate()
1234        {
1235            md.push_str(&format!("{}. {}\n", idx + 1, task_summary));
1236        }
1237        if summary.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY {
1238            md.push_str(&format!(
1239                "- ... and {} more\n",
1240                summary.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY
1241            ));
1242        }
1243        md.push('\n');
1244    }
1245
1246    // Files Modified
1247    if !summary.files_modified.is_empty() {
1248        md.push_str("## Files Modified\n");
1249        for fc in &summary.files_modified {
1250            md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
1251        }
1252        md.push('\n');
1253    }
1254
1255    // Files Read
1256    if !summary.files_read.is_empty() {
1257        md.push_str("## Files Read\n");
1258        for path in &summary.files_read {
1259            md.push_str(&format!("- `{path}`\n"));
1260        }
1261        md.push('\n');
1262    }
1263
1264    // Shell Commands
1265    if !summary.shell_commands.is_empty() {
1266        md.push_str("## Shell Commands\n");
1267        for cmd in &summary.shell_commands {
1268            let code_str = match cmd.exit_code {
1269                Some(c) => c.to_string(),
1270                None => "?".to_string(),
1271            };
1272            md.push_str(&format!(
1273                "- `{}` → {}\n",
1274                truncate_str(&cmd.command, 80),
1275                code_str
1276            ));
1277        }
1278        md.push('\n');
1279    }
1280
1281    // Errors
1282    if !summary.errors.is_empty() {
1283        md.push_str("## Errors\n");
1284        for err in &summary.errors {
1285            md.push_str(&format!("- {err}\n"));
1286        }
1287        md.push('\n');
1288    }
1289
1290    // Key Conversations (user + agent pairs)
1291    if !summary.key_conversations.is_empty() {
1292        md.push_str("## Key Conversations\n");
1293        for (i, conv) in summary.key_conversations.iter().enumerate() {
1294            md.push_str(&format!(
1295                "### {}. User\n{}\n\n### {}. Agent\n{}\n\n",
1296                i + 1,
1297                truncate_str(&conv.user, 300),
1298                i + 1,
1299                truncate_str(&conv.agent, 300),
1300            ));
1301        }
1302    }
1303
1304    // User Messages (fallback list)
1305    if summary.key_conversations.is_empty() && !summary.user_messages.is_empty() {
1306        md.push_str("## User Messages\n");
1307        for (i, msg) in summary.user_messages.iter().enumerate() {
1308            md.push_str(&format!("{}. {}\n", i + 1, truncate_str(msg, 150)));
1309        }
1310        md.push('\n');
1311    }
1312
1313    md
1314}
1315
1316/// Generate a Markdown handoff document from a merged multi-session handoff.
1317pub fn generate_merged_handoff_markdown(merged: &MergedHandoff) -> String {
1318    const MAX_TASK_SUMMARIES_DISPLAY: usize = 3;
1319    let mut md = String::new();
1320
1321    md.push_str("# Merged Session Handoff\n\n");
1322    md.push_str(&format!(
1323        "**Sessions:** {} | **Total Duration:** {}\n\n",
1324        merged.source_session_ids.len(),
1325        format_duration(merged.total_duration_seconds)
1326    ));
1327
1328    // Per-session summaries
1329    for (i, s) in merged.summaries.iter().enumerate() {
1330        md.push_str(&format!(
1331            "---\n\n## Session {} — {}\n\n",
1332            i + 1,
1333            s.source_session_id
1334        ));
1335        md.push_str(&format!("**Objective:** {}\n\n", s.objective));
1336        md.push_str(&format!(
1337            "- **Tool:** {} ({}) | **Duration:** {}\n",
1338            s.tool,
1339            s.model,
1340            format_duration(s.duration_seconds)
1341        ));
1342        md.push_str(&format!(
1343            "- **Messages:** {} | Tool calls: {} | Events: {}\n\n",
1344            s.stats.message_count, s.stats.tool_call_count, s.stats.event_count
1345        ));
1346
1347        if !s.task_summaries.is_empty() {
1348            md.push_str("### Task Summaries\n");
1349            for (j, task_summary) in s
1350                .task_summaries
1351                .iter()
1352                .take(MAX_TASK_SUMMARIES_DISPLAY)
1353                .enumerate()
1354            {
1355                md.push_str(&format!("{}. {}\n", j + 1, task_summary));
1356            }
1357            if s.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY {
1358                md.push_str(&format!(
1359                    "- ... and {} more\n",
1360                    s.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY
1361                ));
1362            }
1363            md.push('\n');
1364        }
1365
1366        // Key Conversations for this session
1367        if !s.key_conversations.is_empty() {
1368            md.push_str("### Conversations\n");
1369            for (j, conv) in s.key_conversations.iter().enumerate() {
1370                md.push_str(&format!(
1371                    "**{}. User:** {}\n\n**{}. Agent:** {}\n\n",
1372                    j + 1,
1373                    truncate_str(&conv.user, 200),
1374                    j + 1,
1375                    truncate_str(&conv.agent, 200),
1376                ));
1377            }
1378        }
1379    }
1380
1381    // Combined files
1382    md.push_str("---\n\n## All Files Modified\n");
1383    if merged.all_files_modified.is_empty() {
1384        md.push_str("_(none)_\n");
1385    } else {
1386        for fc in &merged.all_files_modified {
1387            md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
1388        }
1389    }
1390    md.push('\n');
1391
1392    if !merged.all_files_read.is_empty() {
1393        md.push_str("## All Files Read\n");
1394        for path in &merged.all_files_read {
1395            md.push_str(&format!("- `{path}`\n"));
1396        }
1397        md.push('\n');
1398    }
1399
1400    // Errors
1401    if !merged.total_errors.is_empty() {
1402        md.push_str("## All Errors\n");
1403        for err in &merged.total_errors {
1404            md.push_str(&format!("- {err}\n"));
1405        }
1406        md.push('\n');
1407    }
1408
1409    md
1410}
1411
1412// ─── Summary HAIL generation ─────────────────────────────────────────────────
1413
1414/// Generate a summary HAIL session from an original session.
1415///
1416/// Filters events to only include important ones and truncates content.
1417pub fn generate_handoff_hail(session: &Session) -> Session {
1418    let mut summary_session = Session {
1419        version: session.version.clone(),
1420        session_id: format!("handoff-{}", session.session_id),
1421        agent: session.agent.clone(),
1422        context: SessionContext {
1423            title: Some(format!(
1424                "Handoff: {}",
1425                session.context.title.as_deref().unwrap_or("(untitled)")
1426            )),
1427            description: session.context.description.clone(),
1428            tags: {
1429                let mut tags = session.context.tags.clone();
1430                if !tags.contains(&"handoff".to_string()) {
1431                    tags.push("handoff".to_string());
1432                }
1433                tags
1434            },
1435            created_at: session.context.created_at,
1436            updated_at: chrono::Utc::now(),
1437            related_session_ids: vec![session.session_id.clone()],
1438            attributes: HashMap::new(),
1439        },
1440        events: Vec::new(),
1441        stats: session.stats.clone(),
1442    };
1443
1444    for event in &session.events {
1445        let keep = matches!(
1446            &event.event_type,
1447            EventType::UserMessage
1448                | EventType::AgentMessage
1449                | EventType::FileEdit { .. }
1450                | EventType::FileCreate { .. }
1451                | EventType::FileDelete { .. }
1452                | EventType::TaskStart { .. }
1453                | EventType::TaskEnd { .. }
1454        ) || matches!(&event.event_type, EventType::ShellCommand { exit_code, .. } if *exit_code != Some(0));
1455
1456        if !keep {
1457            continue;
1458        }
1459
1460        // Truncate content blocks
1461        let truncated_blocks: Vec<ContentBlock> = event
1462            .content
1463            .blocks
1464            .iter()
1465            .map(|block| match block {
1466                ContentBlock::Text { text } => ContentBlock::Text {
1467                    text: truncate_str(text, 300),
1468                },
1469                ContentBlock::Code {
1470                    code,
1471                    language,
1472                    start_line,
1473                } => ContentBlock::Code {
1474                    code: truncate_str(code, 300),
1475                    language: language.clone(),
1476                    start_line: *start_line,
1477                },
1478                other => other.clone(),
1479            })
1480            .collect();
1481
1482        summary_session.events.push(Event {
1483            event_id: event.event_id.clone(),
1484            timestamp: event.timestamp,
1485            event_type: event.event_type.clone(),
1486            task_id: event.task_id.clone(),
1487            content: Content {
1488                blocks: truncated_blocks,
1489            },
1490            duration_ms: event.duration_ms,
1491            attributes: HashMap::new(), // strip detailed attributes
1492        });
1493    }
1494
1495    // Recompute stats for the filtered events
1496    summary_session.recompute_stats();
1497
1498    summary_session
1499}
1500
1501// ─── Helpers ─────────────────────────────────────────────────────────────────
1502
1503fn extract_first_user_text(session: &Session) -> Option<String> {
1504    crate::extract::extract_first_user_text(session)
1505}
1506
1507fn extract_objective(session: &Session) -> String {
1508    if let Some(user_text) = extract_first_user_text(session).filter(|t| !t.trim().is_empty()) {
1509        return truncate_str(&collapse_whitespace(&user_text), 200);
1510    }
1511
1512    if let Some(task_title) = session
1513        .events
1514        .iter()
1515        .find_map(|event| match &event.event_type {
1516            EventType::TaskStart { title: Some(title) } => {
1517                let title = title.trim();
1518                if title.is_empty() {
1519                    None
1520                } else {
1521                    Some(title.to_string())
1522                }
1523            }
1524            _ => None,
1525        })
1526    {
1527        return truncate_str(&collapse_whitespace(&task_title), 200);
1528    }
1529
1530    if let Some(task_summary) = session
1531        .events
1532        .iter()
1533        .find_map(|event| match &event.event_type {
1534            EventType::TaskEnd {
1535                summary: Some(summary),
1536            } => {
1537                let summary = summary.trim();
1538                if summary.is_empty() {
1539                    None
1540                } else {
1541                    Some(summary.to_string())
1542                }
1543            }
1544            _ => None,
1545        })
1546    {
1547        return truncate_str(&collapse_whitespace(&task_summary), 200);
1548    }
1549
1550    if let Some(title) = session.context.title.as_deref().map(str::trim) {
1551        if !title.is_empty() {
1552            return truncate_str(&collapse_whitespace(title), 200);
1553        }
1554    }
1555
1556    "(objective unavailable)".to_string()
1557}
1558
1559fn extract_text_from_event(event: &Event) -> Option<String> {
1560    for block in &event.content.blocks {
1561        if let ContentBlock::Text { text } = block {
1562            let trimmed = text.trim();
1563            if !trimmed.is_empty() {
1564                return Some(trimmed.to_string());
1565            }
1566        }
1567    }
1568    None
1569}
1570
1571fn collapse_whitespace(input: &str) -> String {
1572    input.split_whitespace().collect::<Vec<_>>().join(" ")
1573}
1574
1575/// Format seconds into a human-readable duration string.
1576pub fn format_duration(seconds: u64) -> String {
1577    if seconds < 60 {
1578        format!("{seconds}s")
1579    } else if seconds < 3600 {
1580        let m = seconds / 60;
1581        let s = seconds % 60;
1582        format!("{m}m {s}s")
1583    } else {
1584        let h = seconds / 3600;
1585        let m = (seconds % 3600) / 60;
1586        let s = seconds % 60;
1587        format!("{h}h {m}m {s}s")
1588    }
1589}
1590
1591// ─── Tests ───────────────────────────────────────────────────────────────────
1592
1593#[cfg(test)]
1594mod tests {
1595    use super::*;
1596    use crate::{testing, Agent};
1597
1598    fn make_agent() -> Agent {
1599        testing::agent()
1600    }
1601
1602    fn make_event(event_type: EventType, text: &str) -> Event {
1603        testing::event(event_type, text)
1604    }
1605
1606    #[test]
1607    fn test_format_duration() {
1608        assert_eq!(format_duration(0), "0s");
1609        assert_eq!(format_duration(45), "45s");
1610        assert_eq!(format_duration(90), "1m 30s");
1611        assert_eq!(format_duration(750), "12m 30s");
1612        assert_eq!(format_duration(3661), "1h 1m 1s");
1613    }
1614
1615    #[test]
1616    fn test_handoff_summary_from_session() {
1617        let mut session = Session::new("test-id".to_string(), make_agent());
1618        session.stats = Stats {
1619            event_count: 10,
1620            message_count: 3,
1621            tool_call_count: 5,
1622            duration_seconds: 750,
1623            ..Default::default()
1624        };
1625        session
1626            .events
1627            .push(make_event(EventType::UserMessage, "Fix the build error"));
1628        session
1629            .events
1630            .push(make_event(EventType::AgentMessage, "I'll fix it now"));
1631        session.events.push(make_event(
1632            EventType::FileEdit {
1633                path: "src/main.rs".to_string(),
1634                diff: None,
1635            },
1636            "",
1637        ));
1638        session.events.push(make_event(
1639            EventType::FileRead {
1640                path: "Cargo.toml".to_string(),
1641            },
1642            "",
1643        ));
1644        session.events.push(make_event(
1645            EventType::ShellCommand {
1646                command: "cargo build".to_string(),
1647                exit_code: Some(0),
1648            },
1649            "",
1650        ));
1651        session.events.push(make_event(
1652            EventType::TaskEnd {
1653                summary: Some("Build now passes in local env".to_string()),
1654            },
1655            "",
1656        ));
1657
1658        let summary = HandoffSummary::from_session(&session);
1659
1660        assert_eq!(summary.source_session_id, "test-id");
1661        assert_eq!(summary.objective, "Fix the build error");
1662        assert_eq!(summary.files_modified.len(), 1);
1663        assert_eq!(summary.files_modified[0].path, "src/main.rs");
1664        assert_eq!(summary.files_modified[0].action, "edited");
1665        assert_eq!(summary.files_read, vec!["Cargo.toml"]);
1666        assert_eq!(summary.shell_commands.len(), 1);
1667        assert_eq!(
1668            summary.task_summaries,
1669            vec!["Build now passes in local env".to_string()]
1670        );
1671        assert_eq!(summary.key_conversations.len(), 1);
1672        assert_eq!(summary.key_conversations[0].user, "Fix the build error");
1673        assert_eq!(summary.key_conversations[0].agent, "I'll fix it now");
1674    }
1675
1676    #[test]
1677    fn test_handoff_objective_falls_back_to_task_title() {
1678        let mut session = Session::new("task-title-fallback".to_string(), make_agent());
1679        session.context.title = Some("session-019c-example.jsonl".to_string());
1680        session.events.push(make_event(
1681            EventType::TaskStart {
1682                title: Some("Refactor auth middleware for oauth callback".to_string()),
1683            },
1684            "",
1685        ));
1686
1687        let summary = HandoffSummary::from_session(&session);
1688        assert_eq!(
1689            summary.objective,
1690            "Refactor auth middleware for oauth callback"
1691        );
1692    }
1693
1694    #[test]
1695    fn test_handoff_task_summaries_are_deduplicated() {
1696        let mut session = Session::new("task-summary-dedupe".to_string(), make_agent());
1697        session.events.push(make_event(
1698            EventType::TaskEnd {
1699                summary: Some("Add worker profile guard".to_string()),
1700            },
1701            "",
1702        ));
1703        session.events.push(make_event(
1704            EventType::TaskEnd {
1705                summary: Some(" ".to_string()),
1706            },
1707            "",
1708        ));
1709        session.events.push(make_event(
1710            EventType::TaskEnd {
1711                summary: Some("Add worker profile guard".to_string()),
1712            },
1713            "",
1714        ));
1715        session.events.push(make_event(
1716            EventType::TaskEnd {
1717                summary: Some("Hide teams nav for worker profile".to_string()),
1718            },
1719            "",
1720        ));
1721
1722        let summary = HandoffSummary::from_session(&session);
1723        assert_eq!(
1724            summary.task_summaries,
1725            vec![
1726                "Add worker profile guard".to_string(),
1727                "Hide teams nav for worker profile".to_string()
1728            ]
1729        );
1730    }
1731
1732    #[test]
1733    fn test_files_read_excludes_modified() {
1734        let mut session = Session::new("test-id".to_string(), make_agent());
1735        session
1736            .events
1737            .push(make_event(EventType::UserMessage, "test"));
1738        session.events.push(make_event(
1739            EventType::FileRead {
1740                path: "src/main.rs".to_string(),
1741            },
1742            "",
1743        ));
1744        session.events.push(make_event(
1745            EventType::FileEdit {
1746                path: "src/main.rs".to_string(),
1747                diff: None,
1748            },
1749            "",
1750        ));
1751        session.events.push(make_event(
1752            EventType::FileRead {
1753                path: "README.md".to_string(),
1754            },
1755            "",
1756        ));
1757
1758        let summary = HandoffSummary::from_session(&session);
1759        assert_eq!(summary.files_read, vec!["README.md"]);
1760        assert_eq!(summary.files_modified.len(), 1);
1761    }
1762
1763    #[test]
1764    fn test_file_create_not_overwritten_by_edit() {
1765        let mut session = Session::new("test-id".to_string(), make_agent());
1766        session
1767            .events
1768            .push(make_event(EventType::UserMessage, "test"));
1769        session.events.push(make_event(
1770            EventType::FileCreate {
1771                path: "new_file.rs".to_string(),
1772            },
1773            "",
1774        ));
1775        session.events.push(make_event(
1776            EventType::FileEdit {
1777                path: "new_file.rs".to_string(),
1778                diff: None,
1779            },
1780            "",
1781        ));
1782
1783        let summary = HandoffSummary::from_session(&session);
1784        assert_eq!(summary.files_modified[0].action, "created");
1785    }
1786
1787    #[test]
1788    fn test_shell_error_captured() {
1789        let mut session = Session::new("test-id".to_string(), make_agent());
1790        session
1791            .events
1792            .push(make_event(EventType::UserMessage, "test"));
1793        session.events.push(make_event(
1794            EventType::ShellCommand {
1795                command: "cargo test".to_string(),
1796                exit_code: Some(1),
1797            },
1798            "",
1799        ));
1800
1801        let summary = HandoffSummary::from_session(&session);
1802        assert_eq!(summary.errors.len(), 1);
1803        assert!(summary.errors[0].contains("cargo test"));
1804    }
1805
1806    #[test]
1807    fn test_generate_handoff_markdown() {
1808        let mut session = Session::new("test-id".to_string(), make_agent());
1809        session.stats = Stats {
1810            event_count: 10,
1811            message_count: 3,
1812            tool_call_count: 5,
1813            duration_seconds: 750,
1814            ..Default::default()
1815        };
1816        session
1817            .events
1818            .push(make_event(EventType::UserMessage, "Fix the build error"));
1819        session
1820            .events
1821            .push(make_event(EventType::AgentMessage, "I'll fix it now"));
1822        session.events.push(make_event(
1823            EventType::FileEdit {
1824                path: "src/main.rs".to_string(),
1825                diff: None,
1826            },
1827            "",
1828        ));
1829        session.events.push(make_event(
1830            EventType::ShellCommand {
1831                command: "cargo build".to_string(),
1832                exit_code: Some(0),
1833            },
1834            "",
1835        ));
1836        session.events.push(make_event(
1837            EventType::TaskEnd {
1838                summary: Some("Compile error fixed by updating trait bounds".to_string()),
1839            },
1840            "",
1841        ));
1842
1843        let summary = HandoffSummary::from_session(&session);
1844        let md = generate_handoff_markdown(&summary);
1845
1846        assert!(md.contains("# Session Handoff"));
1847        assert!(md.contains("Fix the build error"));
1848        assert!(md.contains("claude-code (claude-opus-4-6)"));
1849        assert!(md.contains("12m 30s"));
1850        assert!(md.contains("## Task Summaries"));
1851        assert!(md.contains("Compile error fixed by updating trait bounds"));
1852        assert!(md.contains("`src/main.rs` (edited)"));
1853        assert!(md.contains("`cargo build` → 0"));
1854        assert!(md.contains("## Key Conversations"));
1855    }
1856
1857    #[test]
1858    fn test_merge_summaries() {
1859        let mut s1 = Session::new("session-a".to_string(), make_agent());
1860        s1.stats.duration_seconds = 100;
1861        s1.events.push(make_event(EventType::UserMessage, "task A"));
1862        s1.events.push(make_event(
1863            EventType::FileEdit {
1864                path: "a.rs".to_string(),
1865                diff: None,
1866            },
1867            "",
1868        ));
1869
1870        let mut s2 = Session::new("session-b".to_string(), make_agent());
1871        s2.stats.duration_seconds = 200;
1872        s2.events.push(make_event(EventType::UserMessage, "task B"));
1873        s2.events.push(make_event(
1874            EventType::FileEdit {
1875                path: "b.rs".to_string(),
1876                diff: None,
1877            },
1878            "",
1879        ));
1880
1881        let sum1 = HandoffSummary::from_session(&s1);
1882        let sum2 = HandoffSummary::from_session(&s2);
1883        let merged = merge_summaries(&[sum1, sum2]);
1884
1885        assert_eq!(merged.source_session_ids.len(), 2);
1886        assert_eq!(merged.total_duration_seconds, 300);
1887        assert_eq!(merged.all_files_modified.len(), 2);
1888    }
1889
1890    #[test]
1891    fn test_generate_handoff_hail() {
1892        let mut session = Session::new("test-id".to_string(), make_agent());
1893        session
1894            .events
1895            .push(make_event(EventType::UserMessage, "Hello"));
1896        session
1897            .events
1898            .push(make_event(EventType::AgentMessage, "Hi there"));
1899        session.events.push(make_event(
1900            EventType::FileRead {
1901                path: "foo.rs".to_string(),
1902            },
1903            "",
1904        ));
1905        session.events.push(make_event(
1906            EventType::FileEdit {
1907                path: "foo.rs".to_string(),
1908                diff: Some("+added line".to_string()),
1909            },
1910            "",
1911        ));
1912        session.events.push(make_event(
1913            EventType::ShellCommand {
1914                command: "cargo build".to_string(),
1915                exit_code: Some(0),
1916            },
1917            "",
1918        ));
1919
1920        let hail = generate_handoff_hail(&session);
1921
1922        assert!(hail.session_id.starts_with("handoff-"));
1923        assert_eq!(hail.context.related_session_ids, vec!["test-id"]);
1924        assert!(hail.context.tags.contains(&"handoff".to_string()));
1925        // FileRead and successful ShellCommand should be filtered out
1926        assert_eq!(hail.events.len(), 3); // UserMessage, AgentMessage, FileEdit
1927                                          // Verify HAIL roundtrip
1928        let jsonl = hail.to_jsonl().unwrap();
1929        let parsed = Session::from_jsonl(&jsonl).unwrap();
1930        assert_eq!(parsed.session_id, hail.session_id);
1931    }
1932
1933    #[test]
1934    fn test_generate_handoff_markdown_v2_section_order() {
1935        let mut session = Session::new("v2-sections".to_string(), make_agent());
1936        session
1937            .events
1938            .push(make_event(EventType::UserMessage, "Implement handoff v2"));
1939        session.events.push(make_event(
1940            EventType::FileEdit {
1941                path: "crates/core/src/handoff.rs".to_string(),
1942                diff: None,
1943            },
1944            "",
1945        ));
1946        session.events.push(make_event(
1947            EventType::ShellCommand {
1948                command: "cargo test".to_string(),
1949                exit_code: Some(0),
1950            },
1951            "",
1952        ));
1953
1954        let summary = HandoffSummary::from_session(&session);
1955        let md = generate_handoff_markdown_v2(&summary);
1956
1957        let order = [
1958            "## Objective",
1959            "## Current State",
1960            "## Next Actions (ordered)",
1961            "## Verification",
1962            "## Blockers / Decisions",
1963            "## Evidence Index",
1964            "## Conversations",
1965            "## User Messages",
1966        ];
1967
1968        let mut last_idx = 0usize;
1969        for section in order {
1970            let idx = md.find(section).unwrap();
1971            assert!(
1972                idx >= last_idx,
1973                "section order mismatch for {section}: idx={idx}, last={last_idx}"
1974            );
1975            last_idx = idx;
1976        }
1977    }
1978
1979    #[test]
1980    fn test_execution_contract_and_verification_from_failed_command() {
1981        let mut session = Session::new("failed-check".to_string(), make_agent());
1982        session
1983            .events
1984            .push(make_event(EventType::UserMessage, "Fix failing tests"));
1985        session.events.push(make_event(
1986            EventType::FileEdit {
1987                path: "src/lib.rs".to_string(),
1988                diff: None,
1989            },
1990            "",
1991        ));
1992        session.events.push(make_event(
1993            EventType::ShellCommand {
1994                command: "cargo test".to_string(),
1995                exit_code: Some(1),
1996            },
1997            "",
1998        ));
1999
2000        let summary = HandoffSummary::from_session(&session);
2001        assert!(summary
2002            .verification
2003            .checks_failed
2004            .contains(&"cargo test".to_string()));
2005        assert!(summary
2006            .execution_contract
2007            .next_actions
2008            .iter()
2009            .any(|action| action.contains("cargo test")));
2010        assert_eq!(
2011            summary.execution_contract.ordered_commands.first(),
2012            Some(&"cargo test".to_string())
2013        );
2014        assert!(summary.execution_contract.rollback_hint.is_none());
2015        assert!(summary
2016            .execution_contract
2017            .rollback_hint_missing_reason
2018            .is_some());
2019    }
2020
2021    #[test]
2022    fn test_validate_handoff_summary_flags_missing_objective() {
2023        let session = Session::new("missing-objective".to_string(), make_agent());
2024        let summary = HandoffSummary::from_session(&session);
2025        let report = validate_handoff_summary(&summary);
2026
2027        assert!(!report.passed);
2028        assert!(report
2029            .findings
2030            .iter()
2031            .any(|f| f.code == "objective_missing"));
2032    }
2033
2034    #[test]
2035    fn test_validate_handoff_summary_flags_cycle() {
2036        let mut session = Session::new("cycle-case".to_string(), make_agent());
2037        session
2038            .events
2039            .push(make_event(EventType::UserMessage, "test"));
2040        let mut summary = HandoffSummary::from_session(&session);
2041        summary.work_packages = vec![
2042            WorkPackage {
2043                id: "a".to_string(),
2044                title: "A".to_string(),
2045                status: "pending".to_string(),
2046                depends_on: vec!["b".to_string()],
2047                files: Vec::new(),
2048                commands: Vec::new(),
2049                evidence_refs: Vec::new(),
2050            },
2051            WorkPackage {
2052                id: "b".to_string(),
2053                title: "B".to_string(),
2054                status: "pending".to_string(),
2055                depends_on: vec!["a".to_string()],
2056                files: Vec::new(),
2057                commands: Vec::new(),
2058                evidence_refs: Vec::new(),
2059            },
2060        ];
2061
2062        let report = validate_handoff_summary(&summary);
2063        assert!(report
2064            .findings
2065            .iter()
2066            .any(|f| f.code == "work_package_cycle"));
2067    }
2068
2069    #[test]
2070    fn test_validate_handoff_summary_requires_next_actions_for_failed_checks() {
2071        let mut session = Session::new("missing-next-action".to_string(), make_agent());
2072        session
2073            .events
2074            .push(make_event(EventType::UserMessage, "test"));
2075        let mut summary = HandoffSummary::from_session(&session);
2076        summary.verification.checks_run = vec![CheckRun {
2077            command: "cargo test".to_string(),
2078            status: "failed".to_string(),
2079            exit_code: Some(1),
2080            event_id: "evt-1".to_string(),
2081        }];
2082        summary.execution_contract.next_actions.clear();
2083
2084        let report = validate_handoff_summary(&summary);
2085        assert!(report
2086            .findings
2087            .iter()
2088            .any(|f| f.code == "next_actions_missing"));
2089    }
2090
2091    #[test]
2092    fn test_validate_handoff_summary_flags_missing_objective_evidence() {
2093        let mut session = Session::new("missing-objective-evidence".to_string(), make_agent());
2094        session
2095            .events
2096            .push(make_event(EventType::UserMessage, "keep objective"));
2097        let mut summary = HandoffSummary::from_session(&session);
2098        summary.evidence = vec![EvidenceRef {
2099            id: "evidence-1".to_string(),
2100            claim: "task_done: something".to_string(),
2101            event_id: "evt".to_string(),
2102            timestamp: "2026-02-01T00:00:00Z".to_string(),
2103            source_type: "TaskEnd".to_string(),
2104        }];
2105
2106        let report = validate_handoff_summary(&summary);
2107        assert!(report
2108            .findings
2109            .iter()
2110            .any(|f| f.code == "objective_evidence_missing"));
2111    }
2112}