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