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 parallel_actions: Vec<String>,
40    pub ordered_steps: Vec<OrderedStep>,
41    pub ordered_commands: Vec<String>,
42    pub rollback_hint: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub rollback_hint_missing_reason: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub rollback_hint_undefined_reason: Option<String>,
47}
48
49#[derive(Debug, Clone, serde::Serialize)]
50pub struct OrderedStep {
51    pub sequence: u32,
52    pub work_package_id: String,
53    pub title: String,
54    pub status: String,
55    pub depends_on: Vec<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub started_at: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub completed_at: Option<String>,
60    pub evidence_refs: Vec<String>,
61}
62
63#[derive(Debug, Clone, serde::Serialize, Default)]
64pub struct Uncertainty {
65    pub assumptions: Vec<String>,
66    pub open_questions: Vec<String>,
67    pub decision_required: Vec<String>,
68}
69
70#[derive(Debug, Clone, serde::Serialize)]
71pub struct CheckRun {
72    pub command: String,
73    pub status: String,
74    pub exit_code: Option<i32>,
75    pub event_id: String,
76}
77
78#[derive(Debug, Clone, serde::Serialize, Default)]
79pub struct Verification {
80    pub checks_run: Vec<CheckRun>,
81    pub checks_passed: Vec<String>,
82    pub checks_failed: Vec<String>,
83    pub required_checks_missing: Vec<String>,
84}
85
86#[derive(Debug, Clone, serde::Serialize)]
87pub struct EvidenceRef {
88    pub id: String,
89    pub claim: String,
90    pub event_id: String,
91    pub timestamp: String,
92    pub source_type: String,
93}
94
95#[derive(Debug, Clone, serde::Serialize)]
96pub struct WorkPackage {
97    pub id: String,
98    pub title: String,
99    pub status: String,
100    pub sequence: u32,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub started_at: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub completed_at: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub outcome: Option<String>,
107    pub depends_on: Vec<String>,
108    pub files: Vec<String>,
109    pub commands: Vec<String>,
110    pub evidence_refs: Vec<String>,
111}
112
113#[derive(Debug, Clone, serde::Serialize)]
114pub struct UndefinedField {
115    pub path: String,
116    pub undefined_reason: String,
117}
118
119/// Summary extracted from a single session.
120#[derive(Debug, Clone, serde::Serialize)]
121pub struct HandoffSummary {
122    pub source_session_id: String,
123    pub objective: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub objective_undefined_reason: Option<String>,
126    pub tool: String,
127    pub model: String,
128    pub duration_seconds: u64,
129    pub stats: Stats,
130    pub files_modified: Vec<FileChange>,
131    pub files_read: Vec<String>,
132    pub shell_commands: Vec<ShellCmd>,
133    pub errors: Vec<String>,
134    pub task_summaries: Vec<String>,
135    pub key_conversations: Vec<Conversation>,
136    pub user_messages: Vec<String>,
137    pub execution_contract: ExecutionContract,
138    pub uncertainty: Uncertainty,
139    pub verification: Verification,
140    pub evidence: Vec<EvidenceRef>,
141    pub work_packages: Vec<WorkPackage>,
142    pub undefined_fields: Vec<UndefinedField>,
143}
144
145/// Merged handoff from multiple sessions.
146#[derive(Debug, Clone, serde::Serialize)]
147pub struct MergedHandoff {
148    pub source_session_ids: Vec<String>,
149    pub summaries: Vec<HandoffSummary>,
150    /// Deduplicated union of all modified files
151    pub all_files_modified: Vec<FileChange>,
152    /// Deduplicated union of all files read (minus modified)
153    pub all_files_read: Vec<String>,
154    pub total_duration_seconds: u64,
155    pub total_errors: Vec<String>,
156}
157
158// ─── Extraction ──────────────────────────────────────────────────────────────
159
160const MAX_KEY_CONVERSATIONS: usize = 12;
161const MAX_USER_MESSAGES: usize = 18;
162const HEAD_KEEP_MESSAGES: usize = 3;
163const HEAD_KEEP_CONVERSATIONS: usize = 2;
164
165impl HandoffSummary {
166    /// Extract a structured summary from a parsed session.
167    pub fn from_session(session: &Session) -> Self {
168        let objective = extract_objective(session);
169        let objective_undefined_reason = objective_unavailable_reason(&objective);
170
171        let files_modified = collect_file_changes(&session.events);
172        let modified_paths: HashSet<&str> =
173            files_modified.iter().map(|f| f.path.as_str()).collect();
174        let files_read = collect_files_read(&session.events, &modified_paths);
175        let shell_commands = collect_shell_commands(&session.events);
176        let errors = collect_errors(&session.events);
177        let task_summaries = collect_task_summaries(&session.events);
178        let user_messages = collect_user_messages(&session.events);
179        let key_conversations = collect_conversation_pairs(&session.events);
180        let verification = collect_verification(&session.events);
181        let uncertainty = collect_uncertainty(session, &verification);
182        let evidence = collect_evidence(session, &objective, &task_summaries, &uncertainty);
183        let work_packages = build_work_packages(&session.events, &evidence);
184        let execution_contract = build_execution_contract(
185            &task_summaries,
186            &verification,
187            &uncertainty,
188            &shell_commands,
189            &files_modified,
190            &work_packages,
191        );
192        let undefined_fields = collect_undefined_fields(
193            objective_undefined_reason.as_deref(),
194            &execution_contract,
195            &evidence,
196        );
197
198        HandoffSummary {
199            source_session_id: session.session_id.clone(),
200            objective,
201            objective_undefined_reason,
202            tool: session.agent.tool.clone(),
203            model: session.agent.model.clone(),
204            duration_seconds: session.stats.duration_seconds,
205            stats: session.stats.clone(),
206            files_modified,
207            files_read,
208            shell_commands,
209            errors,
210            task_summaries,
211            key_conversations,
212            user_messages,
213            execution_contract,
214            uncertainty,
215            verification,
216            evidence,
217            work_packages,
218            undefined_fields,
219        }
220    }
221}
222
223// ─── Functional extractors ──────────────────────────────────────────────────
224
225/// Collect file changes, preserving create/delete precedence over edits.
226fn collect_file_changes(events: &[Event]) -> Vec<FileChange> {
227    let map = events.iter().fold(HashMap::new(), |mut map, event| {
228        match &event.event_type {
229            EventType::FileCreate { path } => {
230                map.insert(path.clone(), "created");
231            }
232            EventType::FileEdit { path, .. } => {
233                map.entry(path.clone()).or_insert("edited");
234            }
235            EventType::FileDelete { path } => {
236                map.insert(path.clone(), "deleted");
237            }
238            _ => {}
239        }
240        map
241    });
242    let mut result: Vec<FileChange> = map
243        .into_iter()
244        .map(|(path, action)| FileChange { path, action })
245        .collect();
246    result.sort_by(|a, b| a.path.cmp(&b.path));
247    result
248}
249
250/// Collect read-only file paths (excluding those that were also modified).
251fn collect_files_read(events: &[Event], modified_paths: &HashSet<&str>) -> Vec<String> {
252    let mut read: Vec<String> = events
253        .iter()
254        .filter_map(|e| match &e.event_type {
255            EventType::FileRead { path } if !modified_paths.contains(path.as_str()) => {
256                Some(path.clone())
257            }
258            _ => None,
259        })
260        .collect::<HashSet<_>>()
261        .into_iter()
262        .collect();
263    read.sort();
264    read
265}
266
267fn collect_shell_commands(events: &[Event]) -> Vec<ShellCmd> {
268    events
269        .iter()
270        .filter_map(|event| match &event.event_type {
271            EventType::ShellCommand { command, exit_code } => Some(ShellCmd {
272                command: command.clone(),
273                exit_code: *exit_code,
274            }),
275            _ => None,
276        })
277        .collect()
278}
279
280/// Collect errors from failed shell commands and tool results.
281fn collect_errors(events: &[Event]) -> Vec<String> {
282    events
283        .iter()
284        .filter_map(|event| match &event.event_type {
285            EventType::ShellCommand { command, exit_code }
286                if *exit_code != Some(0) && exit_code.is_some() =>
287            {
288                Some(format!(
289                    "Shell: `{}` → exit {}",
290                    truncate_str(command, 80),
291                    exit_code.unwrap()
292                ))
293            }
294            EventType::ToolResult {
295                is_error: true,
296                name,
297                ..
298            } => {
299                let detail = extract_text_from_event(event);
300                Some(match detail {
301                    Some(d) => format!("Tool error: {} — {}", name, truncate_str(&d, 80)),
302                    None => format!("Tool error: {name}"),
303                })
304            }
305            _ => None,
306        })
307        .collect()
308}
309
310fn collect_verification(events: &[Event]) -> Verification {
311    let mut tool_result_by_call: HashMap<String, (&Event, bool)> = HashMap::new();
312    for event in events {
313        if let EventType::ToolResult { is_error, .. } = &event.event_type {
314            if let Some(call_id) = event.semantic_call_id() {
315                tool_result_by_call
316                    .entry(call_id.to_string())
317                    .or_insert((event, *is_error));
318            }
319        }
320    }
321
322    let mut checks_run = Vec::new();
323    for event in events {
324        let EventType::ShellCommand { command, exit_code } = &event.event_type else {
325            continue;
326        };
327
328        let (status, resolved_exit_code) = match exit_code {
329            Some(0) => ("passed".to_string(), Some(0)),
330            Some(code) => ("failed".to_string(), Some(*code)),
331            None => {
332                if let Some(call_id) = event.semantic_call_id() {
333                    if let Some((_, is_error)) = tool_result_by_call.get(call_id) {
334                        if *is_error {
335                            ("failed".to_string(), None)
336                        } else {
337                            ("passed".to_string(), None)
338                        }
339                    } else {
340                        ("unknown".to_string(), None)
341                    }
342                } else {
343                    ("unknown".to_string(), None)
344                }
345            }
346        };
347
348        checks_run.push(CheckRun {
349            command: collapse_whitespace(command),
350            status,
351            exit_code: resolved_exit_code,
352            event_id: event.event_id.clone(),
353        });
354    }
355
356    let mut checks_passed: Vec<String> = checks_run
357        .iter()
358        .filter(|run| run.status == "passed")
359        .map(|run| run.command.clone())
360        .collect();
361    let mut checks_failed: Vec<String> = checks_run
362        .iter()
363        .filter(|run| run.status == "failed")
364        .map(|run| run.command.clone())
365        .collect();
366
367    dedupe_keep_order(&mut checks_passed);
368    dedupe_keep_order(&mut checks_failed);
369
370    let unresolved_failed = unresolved_failed_commands(&checks_run);
371    let mut required_checks_missing = unresolved_failed
372        .iter()
373        .map(|cmd| format!("Unresolved failed check: `{cmd}`"))
374        .collect::<Vec<_>>();
375
376    let has_modified_files = events.iter().any(|event| {
377        matches!(
378            event.event_type,
379            EventType::FileEdit { .. }
380                | EventType::FileCreate { .. }
381                | EventType::FileDelete { .. }
382        )
383    });
384    if has_modified_files && checks_run.is_empty() {
385        required_checks_missing
386            .push("No verification command found after file modifications.".to_string());
387    }
388
389    Verification {
390        checks_run,
391        checks_passed,
392        checks_failed,
393        required_checks_missing,
394    }
395}
396
397fn collect_uncertainty(session: &Session, verification: &Verification) -> Uncertainty {
398    let mut assumptions = Vec::new();
399    if extract_objective(session) == "(objective unavailable)" {
400        assumptions.push(
401            "Objective inferred as unavailable; downstream agent must restate objective."
402                .to_string(),
403        );
404    }
405
406    let open_questions = collect_open_questions(&session.events);
407
408    let mut decision_required = Vec::new();
409    for event in &session.events {
410        if let EventType::Custom { kind } = &event.event_type {
411            if kind == "turn_aborted" {
412                let reason = event
413                    .attr_str("reason")
414                    .map(String::from)
415                    .unwrap_or_else(|| "turn aborted".to_string());
416                decision_required.push(format!("Turn aborted: {reason}"));
417            }
418        }
419    }
420    for missing in &verification.required_checks_missing {
421        decision_required.push(missing.clone());
422    }
423    for question in &open_questions {
424        decision_required.push(format!("Resolve open question: {question}"));
425    }
426
427    dedupe_keep_order(&mut assumptions);
428    let mut open_questions = open_questions;
429    dedupe_keep_order(&mut open_questions);
430    dedupe_keep_order(&mut decision_required);
431
432    Uncertainty {
433        assumptions,
434        open_questions,
435        decision_required,
436    }
437}
438
439fn build_execution_contract(
440    task_summaries: &[String],
441    verification: &Verification,
442    uncertainty: &Uncertainty,
443    shell_commands: &[ShellCmd],
444    files_modified: &[FileChange],
445    work_packages: &[WorkPackage],
446) -> ExecutionContract {
447    let ordered_steps = work_packages
448        .iter()
449        .filter(|pkg| is_material_work_package(pkg))
450        .map(|pkg| OrderedStep {
451            sequence: pkg.sequence,
452            work_package_id: pkg.id.clone(),
453            title: pkg.title.clone(),
454            status: pkg.status.clone(),
455            depends_on: pkg.depends_on.clone(),
456            started_at: pkg.started_at.clone(),
457            completed_at: pkg.completed_at.clone(),
458            evidence_refs: pkg.evidence_refs.clone(),
459        })
460        .collect::<Vec<_>>();
461
462    let mut done_definition = ordered_steps
463        .iter()
464        .filter(|step| step.status == "completed")
465        .map(|step| {
466            let pkg = work_packages
467                .iter()
468                .find(|pkg| pkg.id == step.work_package_id)
469                .expect("ordered step must map to existing work package");
470            let mut details = Vec::new();
471            if let Some(outcome) = pkg.outcome.as_deref() {
472                details.push(format!("outcome: {}", truncate_str(outcome, 140)));
473            }
474            let footprint = work_package_footprint(pkg);
475            if !footprint.is_empty() {
476                details.push(footprint);
477            }
478            let at = step
479                .completed_at
480                .as_deref()
481                .or(step.started_at.as_deref())
482                .unwrap_or("time-unavailable");
483            if details.is_empty() {
484                format!("[{}] Completed `{}` at {}.", step.sequence, step.title, at)
485            } else {
486                format!(
487                    "[{}] Completed `{}` at {} ({}).",
488                    step.sequence,
489                    step.title,
490                    at,
491                    details.join("; ")
492                )
493            }
494        })
495        .collect::<Vec<_>>();
496
497    if !verification.checks_passed.is_empty() {
498        let keep = verification
499            .checks_passed
500            .iter()
501            .take(3)
502            .map(|check| format!("`{check}`"))
503            .collect::<Vec<_>>();
504        let extra = verification.checks_passed.len().saturating_sub(3);
505        if extra > 0 {
506            done_definition.push(format!(
507                "Verification passed: {} (+{} more).",
508                keep.join(", "),
509                extra
510            ));
511        } else {
512            done_definition.push(format!("Verification passed: {}.", keep.join(", ")));
513        }
514    }
515
516    if !files_modified.is_empty() {
517        let keep = files_modified
518            .iter()
519            .take(3)
520            .map(|file| format!("`{}`", file.path))
521            .collect::<Vec<_>>();
522        let extra = files_modified.len().saturating_sub(3);
523        if extra > 0 {
524            done_definition.push(format!(
525                "Changed {} file(s): {} (+{} more).",
526                files_modified.len(),
527                keep.join(", "),
528                extra
529            ));
530        } else {
531            done_definition.push(format!(
532                "Changed {} file(s): {}.",
533                files_modified.len(),
534                keep.join(", ")
535            ));
536        }
537    }
538
539    if done_definition.is_empty() {
540        done_definition.extend(task_summaries.iter().take(5).cloned());
541    }
542    dedupe_keep_order(&mut done_definition);
543
544    let mut next_actions = unresolved_failed_commands(&verification.checks_run)
545        .into_iter()
546        .map(|cmd| format!("Fix and re-run `{cmd}` until the check passes."))
547        .collect::<Vec<_>>();
548    next_actions.extend(
549        verification
550            .required_checks_missing
551            .iter()
552            .map(|missing| format!("Add/restore verification check: {missing}")),
553    );
554    next_actions.extend(ordered_steps.iter().filter_map(|step| {
555        if step.status == "completed" || step.depends_on.is_empty() {
556            return None;
557        }
558        Some(format!(
559            "[{}] After dependencies [{}], execute `{}` ({}).",
560            step.sequence,
561            step.depends_on.join(", "),
562            step.title,
563            step.work_package_id
564        ))
565    }));
566    next_actions.extend(
567        uncertainty
568            .open_questions
569            .iter()
570            .map(|q| format!("Resolve open question: {q}")),
571    );
572    let mut parallel_actions = ordered_steps
573        .iter()
574        .filter(|step| {
575            step.status != "completed"
576                && step.depends_on.is_empty()
577                && step.work_package_id != "main"
578        })
579        .map(|step| {
580            let at = step.started_at.as_deref().unwrap_or("time-unavailable");
581            format!(
582                "[{}] `{}` ({}) — start: {}",
583                step.sequence, step.title, step.work_package_id, at
584            )
585        })
586        .collect::<Vec<_>>();
587
588    if done_definition.is_empty()
589        && next_actions.is_empty()
590        && parallel_actions.is_empty()
591        && ordered_steps.is_empty()
592    {
593        next_actions.push(
594            "Define completion criteria and run at least one verification command.".to_string(),
595        );
596    }
597    dedupe_keep_order(&mut next_actions);
598    dedupe_keep_order(&mut parallel_actions);
599
600    let unresolved = unresolved_failed_commands(&verification.checks_run);
601    let mut ordered_commands = unresolved;
602    for cmd in shell_commands
603        .iter()
604        .map(|c| collapse_whitespace(&c.command))
605    {
606        if !ordered_commands.iter().any(|existing| existing == &cmd) {
607            ordered_commands.push(cmd);
608        }
609    }
610
611    let has_git_commit = shell_commands
612        .iter()
613        .any(|cmd| cmd.command.to_ascii_lowercase().contains("git commit"));
614    let (rollback_hint, rollback_hint_missing_reason) = if has_git_commit {
615        (
616            Some(
617                "Use `git revert <commit>` for committed changes, then re-run verification."
618                    .to_string(),
619            ),
620            None,
621        )
622    } else {
623        (
624            None,
625            Some("No committed change signal found in events.".to_string()),
626        )
627    };
628
629    ExecutionContract {
630        done_definition,
631        next_actions,
632        parallel_actions,
633        ordered_steps,
634        ordered_commands,
635        rollback_hint,
636        rollback_hint_missing_reason: rollback_hint_missing_reason.clone(),
637        rollback_hint_undefined_reason: rollback_hint_missing_reason,
638    }
639}
640
641fn work_package_footprint(pkg: &WorkPackage) -> String {
642    let mut details = Vec::new();
643    if !pkg.files.is_empty() {
644        details.push(format!("files: {}", pkg.files.len()));
645    }
646    if !pkg.commands.is_empty() {
647        details.push(format!("commands: {}", pkg.commands.len()));
648    }
649    details.join(", ")
650}
651
652fn collect_evidence(
653    session: &Session,
654    objective: &str,
655    task_summaries: &[String],
656    uncertainty: &Uncertainty,
657) -> Vec<EvidenceRef> {
658    let mut evidence = Vec::new();
659    let mut next_id = 1usize;
660
661    if let Some(event) = find_objective_event(session) {
662        evidence.push(EvidenceRef {
663            id: format!("evidence-{next_id}"),
664            claim: format!("objective: {objective}"),
665            event_id: event.event_id.clone(),
666            timestamp: event.timestamp.to_rfc3339(),
667            source_type: event_source_type(event),
668        });
669        next_id += 1;
670    }
671
672    for summary in task_summaries {
673        if let Some(event) = find_task_summary_event(&session.events, summary) {
674            evidence.push(EvidenceRef {
675                id: format!("evidence-{next_id}"),
676                claim: format!("task_done: {summary}"),
677                event_id: event.event_id.clone(),
678                timestamp: event.timestamp.to_rfc3339(),
679                source_type: event_source_type(event),
680            });
681            next_id += 1;
682        }
683    }
684
685    for decision in &uncertainty.decision_required {
686        if let Some(event) = find_decision_event(&session.events, decision) {
687            evidence.push(EvidenceRef {
688                id: format!("evidence-{next_id}"),
689                claim: format!("decision_required: {decision}"),
690                event_id: event.event_id.clone(),
691                timestamp: event.timestamp.to_rfc3339(),
692                source_type: event_source_type(event),
693            });
694            next_id += 1;
695        }
696    }
697
698    evidence
699}
700
701fn build_work_packages(events: &[Event], evidence: &[EvidenceRef]) -> Vec<WorkPackage> {
702    #[derive(Default)]
703    struct WorkPackageAcc {
704        title: Option<String>,
705        status: String,
706        outcome: Option<String>,
707        first_ts: Option<chrono::DateTime<chrono::Utc>>,
708        first_idx: Option<usize>,
709        completed_ts: Option<chrono::DateTime<chrono::Utc>>,
710        files: HashSet<String>,
711        commands: Vec<String>,
712        evidence_refs: Vec<String>,
713    }
714
715    let mut evidence_by_event: HashMap<&str, Vec<String>> = HashMap::new();
716    for ev in evidence {
717        evidence_by_event
718            .entry(ev.event_id.as_str())
719            .or_default()
720            .push(ev.id.clone());
721    }
722
723    let mut grouped: BTreeMap<String, WorkPackageAcc> = BTreeMap::new();
724    for (event_idx, event) in events.iter().enumerate() {
725        let key = package_key_for_event(event);
726        let acc = grouped
727            .entry(key.clone())
728            .or_insert_with(|| WorkPackageAcc {
729                status: "pending".to_string(),
730                ..Default::default()
731            });
732
733        if acc.first_ts.is_none() {
734            acc.first_ts = Some(event.timestamp);
735            acc.first_idx = Some(event_idx);
736        }
737        if let Some(ids) = evidence_by_event.get(event.event_id.as_str()) {
738            acc.evidence_refs.extend(ids.clone());
739        }
740
741        match &event.event_type {
742            EventType::TaskStart { title } => {
743                if let Some(title) = title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
744                    acc.title = Some(title.to_string());
745                }
746                if acc.status != "completed" {
747                    acc.status = "in_progress".to_string();
748                }
749            }
750            EventType::TaskEnd { summary } => {
751                acc.status = "completed".to_string();
752                acc.completed_ts = Some(event.timestamp);
753                if let Some(summary) = summary
754                    .as_deref()
755                    .map(collapse_whitespace)
756                    .filter(|summary| !summary.is_empty())
757                {
758                    acc.outcome = Some(summary.clone());
759                    if acc.title.is_none() {
760                        acc.title = Some(truncate_str(&summary, 160));
761                    }
762                }
763            }
764            EventType::FileEdit { path, .. }
765            | EventType::FileCreate { path }
766            | EventType::FileDelete { path } => {
767                acc.files.insert(path.clone());
768                if acc.status == "pending" {
769                    acc.status = "in_progress".to_string();
770                }
771            }
772            EventType::ShellCommand { command, .. } => {
773                acc.commands.push(collapse_whitespace(command));
774                if acc.status == "pending" {
775                    acc.status = "in_progress".to_string();
776                }
777            }
778            _ => {}
779        }
780    }
781
782    let mut by_first_seen = grouped
783        .into_iter()
784        .map(|(id, mut acc)| {
785            dedupe_keep_order(&mut acc.commands);
786            dedupe_keep_order(&mut acc.evidence_refs);
787            let mut files: Vec<String> = acc.files.into_iter().collect();
788            files.sort();
789            (
790                acc.first_ts,
791                acc.first_idx.unwrap_or(usize::MAX),
792                WorkPackage {
793                    title: acc.title.unwrap_or_else(|| {
794                        if id == "main" {
795                            "Main flow".to_string()
796                        } else {
797                            format!("Task {id}")
798                        }
799                    }),
800                    id,
801                    status: acc.status,
802                    sequence: 0,
803                    started_at: acc.first_ts.map(|ts| ts.to_rfc3339()),
804                    completed_at: acc.completed_ts.map(|ts| ts.to_rfc3339()),
805                    outcome: acc.outcome,
806                    depends_on: Vec::new(),
807                    files,
808                    commands: acc.commands,
809                    evidence_refs: acc.evidence_refs,
810                },
811            )
812        })
813        .collect::<Vec<_>>();
814
815    by_first_seen.sort_by(|a, b| {
816        a.0.cmp(&b.0)
817            .then_with(|| a.1.cmp(&b.1))
818            .then_with(|| a.2.id.cmp(&b.2.id))
819    });
820
821    let mut packages = by_first_seen
822        .into_iter()
823        .map(|(_, _, package)| package)
824        .collect::<Vec<_>>();
825
826    for (idx, package) in packages.iter_mut().enumerate() {
827        package.sequence = (idx + 1) as u32;
828    }
829
830    for i in 0..packages.len() {
831        let cur_files: HashSet<&str> = packages[i].files.iter().map(String::as_str).collect();
832        if cur_files.is_empty() {
833            continue;
834        }
835        let mut dependency: Option<String> = None;
836        for j in (0..i).rev() {
837            let prev_files: HashSet<&str> = packages[j].files.iter().map(String::as_str).collect();
838            if !prev_files.is_empty() && !cur_files.is_disjoint(&prev_files) {
839                dependency = Some(packages[j].id.clone());
840                break;
841            }
842        }
843        if let Some(dep) = dependency {
844            packages[i].depends_on.push(dep);
845        }
846    }
847
848    packages.retain(|pkg| pkg.id == "main" || is_material_work_package(pkg));
849    let known_ids: HashSet<String> = packages.iter().map(|pkg| pkg.id.clone()).collect();
850    for pkg in &mut packages {
851        pkg.depends_on.retain(|dep| known_ids.contains(dep));
852    }
853
854    packages
855}
856
857fn is_generic_work_package_title(id: &str, title: &str) -> bool {
858    title == "Main flow" || title == format!("Task {id}")
859}
860
861fn is_material_work_package(pkg: &WorkPackage) -> bool {
862    if !pkg.files.is_empty() || !pkg.commands.is_empty() || pkg.outcome.is_some() {
863        return true;
864    }
865
866    if pkg.id == "main" {
867        return pkg.status != "pending";
868    }
869
870    if !is_generic_work_package_title(&pkg.id, &pkg.title) {
871        return true;
872    }
873
874    pkg.status == "completed" && !pkg.evidence_refs.is_empty()
875}
876
877fn package_key_for_event(event: &Event) -> String {
878    if let Some(task_id) = event
879        .task_id
880        .as_deref()
881        .map(str::trim)
882        .filter(|id| !id.is_empty())
883    {
884        return task_id.to_string();
885    }
886    if let Some(group_id) = event.semantic_group_id() {
887        return group_id.to_string();
888    }
889    "main".to_string()
890}
891
892fn find_objective_event(session: &Session) -> Option<&Event> {
893    session
894        .events
895        .iter()
896        .find(|event| matches!(event.event_type, EventType::UserMessage))
897        .or_else(|| {
898            session.events.iter().find(|event| {
899                matches!(
900                    event.event_type,
901                    EventType::TaskStart { .. } | EventType::TaskEnd { .. }
902                )
903            })
904        })
905}
906
907fn find_task_summary_event<'a>(events: &'a [Event], summary: &str) -> Option<&'a Event> {
908    let normalized_target = collapse_whitespace(summary);
909    events.iter().find(|event| {
910        let EventType::TaskEnd {
911            summary: Some(candidate),
912        } = &event.event_type
913        else {
914            return false;
915        };
916        collapse_whitespace(candidate) == normalized_target
917    })
918}
919
920fn find_decision_event<'a>(events: &'a [Event], decision: &str) -> Option<&'a Event> {
921    if decision.to_ascii_lowercase().contains("turn aborted") {
922        return events.iter().find(|event| {
923            matches!(
924                &event.event_type,
925                EventType::Custom { kind } if kind == "turn_aborted"
926            )
927        });
928    }
929    if decision.to_ascii_lowercase().contains("open question") {
930        return events
931            .iter()
932            .find(|event| event.attr_str("source") == Some("interactive_question"));
933    }
934    None
935}
936
937fn collect_open_questions(events: &[Event]) -> Vec<String> {
938    let mut question_meta: BTreeMap<String, String> = BTreeMap::new();
939    let mut asked_order = Vec::new();
940    let mut answered_ids = HashSet::new();
941
942    for event in events {
943        if event.attr_str("source") == Some("interactive_question") {
944            if let Some(items) = event
945                .attributes
946                .get("question_meta")
947                .and_then(|v| v.as_array())
948            {
949                for item in items {
950                    let Some(id) = item
951                        .get("id")
952                        .and_then(|v| v.as_str())
953                        .map(str::trim)
954                        .filter(|v| !v.is_empty())
955                    else {
956                        continue;
957                    };
958                    let text = item
959                        .get("question")
960                        .or_else(|| item.get("header"))
961                        .and_then(|v| v.as_str())
962                        .map(str::trim)
963                        .filter(|v| !v.is_empty())
964                        .unwrap_or(id);
965                    if !question_meta.contains_key(id) {
966                        asked_order.push(id.to_string());
967                    }
968                    question_meta.insert(id.to_string(), text.to_string());
969                }
970            } else if let Some(ids) = event
971                .attributes
972                .get("question_ids")
973                .and_then(|v| v.as_array())
974                .map(|arr| {
975                    arr.iter()
976                        .filter_map(|v| v.as_str())
977                        .map(str::trim)
978                        .filter(|v| !v.is_empty())
979                        .map(String::from)
980                        .collect::<Vec<_>>()
981                })
982            {
983                for id in ids {
984                    if !question_meta.contains_key(&id) {
985                        asked_order.push(id.clone());
986                    }
987                    question_meta.entry(id.clone()).or_insert(id);
988                }
989            }
990        }
991
992        if event.attr_str("source") == Some("interactive") {
993            if let Some(ids) = event
994                .attributes
995                .get("question_ids")
996                .and_then(|v| v.as_array())
997            {
998                for id in ids
999                    .iter()
1000                    .filter_map(|v| v.as_str())
1001                    .map(str::trim)
1002                    .filter(|v| !v.is_empty())
1003                {
1004                    answered_ids.insert(id.to_string());
1005                }
1006            }
1007        }
1008    }
1009
1010    asked_order
1011        .into_iter()
1012        .filter(|id| !answered_ids.contains(id))
1013        .map(|id| {
1014            let text = question_meta
1015                .get(&id)
1016                .cloned()
1017                .unwrap_or_else(|| id.clone());
1018            format!("{id}: {text}")
1019        })
1020        .collect()
1021}
1022
1023fn unresolved_failed_commands(checks_run: &[CheckRun]) -> Vec<String> {
1024    let mut unresolved = Vec::new();
1025    for (idx, run) in checks_run.iter().enumerate() {
1026        if run.status != "failed" {
1027            continue;
1028        }
1029        let resolved = checks_run
1030            .iter()
1031            .skip(idx + 1)
1032            .any(|later| later.command == run.command && later.status == "passed");
1033        if !resolved {
1034            unresolved.push(run.command.clone());
1035        }
1036    }
1037    dedupe_keep_order(&mut unresolved);
1038    unresolved
1039}
1040
1041fn dedupe_keep_order(values: &mut Vec<String>) {
1042    let mut seen = HashSet::new();
1043    values.retain(|value| seen.insert(value.clone()));
1044}
1045
1046fn objective_unavailable_reason(objective: &str) -> Option<String> {
1047    if objective.trim().is_empty() || objective == "(objective unavailable)" {
1048        Some(
1049            "No user prompt, task title/summary, or session title could be used to infer objective."
1050                .to_string(),
1051        )
1052    } else {
1053        None
1054    }
1055}
1056
1057fn collect_undefined_fields(
1058    objective_undefined_reason: Option<&str>,
1059    execution_contract: &ExecutionContract,
1060    evidence: &[EvidenceRef],
1061) -> Vec<UndefinedField> {
1062    let mut undefined = Vec::new();
1063
1064    if let Some(reason) = objective_undefined_reason {
1065        undefined.push(UndefinedField {
1066            path: "objective".to_string(),
1067            undefined_reason: reason.to_string(),
1068        });
1069    }
1070
1071    if let Some(reason) = execution_contract
1072        .rollback_hint_undefined_reason
1073        .as_deref()
1074        .or(execution_contract.rollback_hint_missing_reason.as_deref())
1075    {
1076        undefined.push(UndefinedField {
1077            path: "execution_contract.rollback_hint".to_string(),
1078            undefined_reason: reason.to_string(),
1079        });
1080    }
1081
1082    if evidence.is_empty() {
1083        undefined.push(UndefinedField {
1084            path: "evidence".to_string(),
1085            undefined_reason:
1086                "No objective/task/decision evidence could be mapped to source events.".to_string(),
1087        });
1088    }
1089
1090    undefined
1091}
1092
1093fn event_source_type(event: &Event) -> String {
1094    event
1095        .source_raw_type()
1096        .map(String::from)
1097        .unwrap_or_else(|| match &event.event_type {
1098            EventType::UserMessage => "UserMessage".to_string(),
1099            EventType::AgentMessage => "AgentMessage".to_string(),
1100            EventType::SystemMessage => "SystemMessage".to_string(),
1101            EventType::Thinking => "Thinking".to_string(),
1102            EventType::ToolCall { .. } => "ToolCall".to_string(),
1103            EventType::ToolResult { .. } => "ToolResult".to_string(),
1104            EventType::FileRead { .. } => "FileRead".to_string(),
1105            EventType::CodeSearch { .. } => "CodeSearch".to_string(),
1106            EventType::FileSearch { .. } => "FileSearch".to_string(),
1107            EventType::FileEdit { .. } => "FileEdit".to_string(),
1108            EventType::FileCreate { .. } => "FileCreate".to_string(),
1109            EventType::FileDelete { .. } => "FileDelete".to_string(),
1110            EventType::ShellCommand { .. } => "ShellCommand".to_string(),
1111            EventType::ImageGenerate { .. } => "ImageGenerate".to_string(),
1112            EventType::VideoGenerate { .. } => "VideoGenerate".to_string(),
1113            EventType::AudioGenerate { .. } => "AudioGenerate".to_string(),
1114            EventType::WebSearch { .. } => "WebSearch".to_string(),
1115            EventType::WebFetch { .. } => "WebFetch".to_string(),
1116            EventType::TaskStart { .. } => "TaskStart".to_string(),
1117            EventType::TaskEnd { .. } => "TaskEnd".to_string(),
1118            EventType::Custom { kind } => format!("Custom:{kind}"),
1119        })
1120}
1121
1122fn collect_task_summaries(events: &[Event]) -> Vec<String> {
1123    let mut seen = HashSet::new();
1124    let mut summaries = Vec::new();
1125
1126    for event in events {
1127        let EventType::TaskEnd {
1128            summary: Some(summary),
1129        } = &event.event_type
1130        else {
1131            continue;
1132        };
1133
1134        let summary = summary.trim();
1135        if summary.is_empty() {
1136            continue;
1137        }
1138
1139        let normalized = collapse_whitespace(summary);
1140        if normalized.eq_ignore_ascii_case("synthetic end (missing task_complete)") {
1141            continue;
1142        }
1143        if seen.insert(normalized.clone()) {
1144            summaries.push(truncate_str(&normalized, 180));
1145        }
1146    }
1147
1148    summaries
1149}
1150
1151fn collect_user_messages(events: &[Event]) -> Vec<String> {
1152    let messages = events
1153        .iter()
1154        .filter(|e| matches!(&e.event_type, EventType::UserMessage))
1155        .filter_map(extract_text_from_event)
1156        .map(|msg| truncate_str(&collapse_whitespace(&msg), 240))
1157        .collect::<Vec<_>>();
1158    condense_head_tail(messages, HEAD_KEEP_MESSAGES, MAX_USER_MESSAGES)
1159}
1160
1161/// Pair adjacent User→Agent messages into conversations.
1162///
1163/// Filters to message events only, then uses `windows(2)` to find
1164/// UserMessage→AgentMessage pairs — no mutable tracking state needed.
1165fn collect_conversation_pairs(events: &[Event]) -> Vec<Conversation> {
1166    let messages: Vec<&Event> = events
1167        .iter()
1168        .filter(|e| {
1169            matches!(
1170                &e.event_type,
1171                EventType::UserMessage | EventType::AgentMessage
1172            )
1173        })
1174        .collect();
1175
1176    let conversations = messages
1177        .windows(2)
1178        .filter_map(|pair| match (&pair[0].event_type, &pair[1].event_type) {
1179            (EventType::UserMessage, EventType::AgentMessage) => {
1180                let user_text = extract_text_from_event(pair[0])?;
1181                let agent_text = extract_text_from_event(pair[1])?;
1182                Some(Conversation {
1183                    user: truncate_str(&user_text, 300),
1184                    agent: truncate_str(&agent_text, 300),
1185                })
1186            }
1187            _ => None,
1188        })
1189        .collect::<Vec<_>>();
1190
1191    condense_head_tail(
1192        conversations,
1193        HEAD_KEEP_CONVERSATIONS,
1194        MAX_KEY_CONVERSATIONS,
1195    )
1196}
1197
1198fn condense_head_tail<T: Clone>(items: Vec<T>, head_keep: usize, max_total: usize) -> Vec<T> {
1199    if items.len() <= max_total {
1200        return items;
1201    }
1202
1203    let max_total = max_total.max(head_keep);
1204    let tail_keep = max_total.saturating_sub(head_keep);
1205    let mut condensed = Vec::with_capacity(max_total);
1206
1207    condensed.extend(items.iter().take(head_keep).cloned());
1208    condensed.extend(
1209        items
1210            .iter()
1211            .skip(items.len().saturating_sub(tail_keep))
1212            .cloned(),
1213    );
1214    condensed
1215}
1216
1217// ─── Merge ───────────────────────────────────────────────────────────────────
1218
1219/// Merge multiple session summaries into a single handoff context.
1220pub fn merge_summaries(summaries: &[HandoffSummary]) -> MergedHandoff {
1221    let session_ids: Vec<String> = summaries
1222        .iter()
1223        .map(|s| s.source_session_id.clone())
1224        .collect();
1225    let total_duration: u64 = summaries.iter().map(|s| s.duration_seconds).sum();
1226    let total_errors: Vec<String> = summaries
1227        .iter()
1228        .flat_map(|s| {
1229            s.errors
1230                .iter()
1231                .map(move |err| format!("[{}] {}", s.source_session_id, err))
1232        })
1233        .collect();
1234
1235    let all_modified: HashMap<String, &str> = summaries
1236        .iter()
1237        .flat_map(|s| &s.files_modified)
1238        .fold(HashMap::new(), |mut map, fc| {
1239            map.entry(fc.path.clone()).or_insert(fc.action);
1240            map
1241        });
1242
1243    // Compute sorted_read before consuming all_modified
1244    let mut sorted_read: Vec<String> = summaries
1245        .iter()
1246        .flat_map(|s| &s.files_read)
1247        .filter(|p| !all_modified.contains_key(p.as_str()))
1248        .cloned()
1249        .collect::<HashSet<_>>()
1250        .into_iter()
1251        .collect();
1252    sorted_read.sort();
1253
1254    let mut sorted_modified: Vec<FileChange> = all_modified
1255        .into_iter()
1256        .map(|(path, action)| FileChange { path, action })
1257        .collect();
1258    sorted_modified.sort_by(|a, b| a.path.cmp(&b.path));
1259
1260    MergedHandoff {
1261        source_session_ids: session_ids,
1262        summaries: summaries.to_vec(),
1263        all_files_modified: sorted_modified,
1264        all_files_read: sorted_read,
1265        total_duration_seconds: total_duration,
1266        total_errors,
1267    }
1268}
1269
1270#[derive(Debug, Clone, serde::Serialize)]
1271pub struct ValidationFinding {
1272    pub code: String,
1273    pub severity: String,
1274    pub message: String,
1275}
1276
1277#[derive(Debug, Clone, serde::Serialize)]
1278pub struct HandoffValidationReport {
1279    pub session_id: String,
1280    pub passed: bool,
1281    pub findings: Vec<ValidationFinding>,
1282}
1283
1284pub fn validate_handoff_summary(summary: &HandoffSummary) -> HandoffValidationReport {
1285    let mut findings = Vec::new();
1286
1287    if summary.objective.trim().is_empty() || summary.objective == "(objective unavailable)" {
1288        findings.push(ValidationFinding {
1289            code: "objective_missing".to_string(),
1290            severity: "warning".to_string(),
1291            message: "Objective is unavailable.".to_string(),
1292        });
1293    }
1294
1295    let unresolved_failures = unresolved_failed_commands(&summary.verification.checks_run);
1296    if !unresolved_failures.is_empty() && summary.execution_contract.next_actions.is_empty() {
1297        findings.push(ValidationFinding {
1298            code: "next_actions_missing".to_string(),
1299            severity: "warning".to_string(),
1300            message: "Unresolved failed checks exist but no next action was generated.".to_string(),
1301        });
1302    }
1303
1304    if !summary.files_modified.is_empty() && summary.verification.checks_run.is_empty() {
1305        findings.push(ValidationFinding {
1306            code: "verification_missing".to_string(),
1307            severity: "warning".to_string(),
1308            message: "Files were modified but no verification check was recorded.".to_string(),
1309        });
1310    }
1311
1312    if summary.evidence.is_empty() {
1313        findings.push(ValidationFinding {
1314            code: "evidence_missing".to_string(),
1315            severity: "warning".to_string(),
1316            message: "No evidence references were generated.".to_string(),
1317        });
1318    } else if !summary
1319        .evidence
1320        .iter()
1321        .any(|ev| ev.claim.starts_with("objective:"))
1322    {
1323        findings.push(ValidationFinding {
1324            code: "objective_evidence_missing".to_string(),
1325            severity: "warning".to_string(),
1326            message: "Objective exists but objective evidence is missing.".to_string(),
1327        });
1328    }
1329
1330    if has_work_package_cycle(&summary.work_packages) {
1331        findings.push(ValidationFinding {
1332            code: "work_package_cycle".to_string(),
1333            severity: "error".to_string(),
1334            message: "work_packages.depends_on contains a cycle.".to_string(),
1335        });
1336    }
1337
1338    let has_material_packages = summary.work_packages.iter().any(is_material_work_package);
1339    if has_material_packages && summary.execution_contract.ordered_steps.is_empty() {
1340        findings.push(ValidationFinding {
1341            code: "ordered_steps_missing".to_string(),
1342            severity: "warning".to_string(),
1343            message: "Material work packages exist but execution_contract.ordered_steps is empty."
1344                .to_string(),
1345        });
1346    } else if !ordered_steps_are_consistent(
1347        &summary.execution_contract.ordered_steps,
1348        &summary.work_packages,
1349    ) {
1350        findings.push(ValidationFinding {
1351            code: "ordered_steps_inconsistent".to_string(),
1352            severity: "error".to_string(),
1353            message:
1354                "execution_contract.ordered_steps is not temporally or referentially consistent."
1355                    .to_string(),
1356        });
1357    }
1358
1359    HandoffValidationReport {
1360        session_id: summary.source_session_id.clone(),
1361        passed: findings.is_empty(),
1362        findings,
1363    }
1364}
1365
1366pub fn validate_handoff_summaries(summaries: &[HandoffSummary]) -> Vec<HandoffValidationReport> {
1367    summaries.iter().map(validate_handoff_summary).collect()
1368}
1369
1370fn has_work_package_cycle(packages: &[WorkPackage]) -> bool {
1371    let mut state: HashMap<&str, u8> = HashMap::new();
1372    let deps: HashMap<&str, Vec<&str>> = packages
1373        .iter()
1374        .map(|wp| {
1375            (
1376                wp.id.as_str(),
1377                wp.depends_on.iter().map(String::as_str).collect::<Vec<_>>(),
1378            )
1379        })
1380        .collect();
1381
1382    fn dfs<'a>(
1383        node: &'a str,
1384        state: &mut HashMap<&'a str, u8>,
1385        deps: &HashMap<&'a str, Vec<&'a str>>,
1386    ) -> bool {
1387        match state.get(node).copied() {
1388            Some(1) => return true,
1389            Some(2) => return false,
1390            _ => {}
1391        }
1392        state.insert(node, 1);
1393        if let Some(children) = deps.get(node) {
1394            for child in children {
1395                if !deps.contains_key(child) {
1396                    continue;
1397                }
1398                if dfs(child, state, deps) {
1399                    return true;
1400                }
1401            }
1402        }
1403        state.insert(node, 2);
1404        false
1405    }
1406
1407    for node in deps.keys().copied() {
1408        if dfs(node, &mut state, &deps) {
1409            return true;
1410        }
1411    }
1412    false
1413}
1414
1415fn ordered_steps_are_consistent(steps: &[OrderedStep], work_packages: &[WorkPackage]) -> bool {
1416    if steps.is_empty() {
1417        return true;
1418    }
1419
1420    if !steps
1421        .windows(2)
1422        .all(|pair| pair[0].sequence < pair[1].sequence)
1423    {
1424        return false;
1425    }
1426
1427    let known_ids = work_packages
1428        .iter()
1429        .map(|pkg| pkg.id.as_str())
1430        .collect::<HashSet<_>>();
1431    if !steps
1432        .iter()
1433        .all(|step| known_ids.contains(step.work_package_id.as_str()))
1434    {
1435        return false;
1436    }
1437
1438    let is_monotonic_time = |left: Option<&str>, right: Option<&str>| -> bool {
1439        match (left, right) {
1440            (Some(l), Some(r)) => {
1441                let left = chrono::DateTime::parse_from_rfc3339(l).ok();
1442                let right = chrono::DateTime::parse_from_rfc3339(r).ok();
1443                match (left, right) {
1444                    (Some(l), Some(r)) => l <= r,
1445                    _ => false,
1446                }
1447            }
1448            _ => true,
1449        }
1450    };
1451
1452    steps
1453        .windows(2)
1454        .all(|pair| is_monotonic_time(pair[0].started_at.as_deref(), pair[1].started_at.as_deref()))
1455}
1456
1457// ─── Markdown generation ─────────────────────────────────────────────────────
1458
1459/// Generate a v2 Markdown handoff document from a single session summary.
1460pub fn generate_handoff_markdown_v2(summary: &HandoffSummary) -> String {
1461    let mut md = String::new();
1462    md.push_str("# Session Handoff\n\n");
1463    append_v2_markdown_sections(&mut md, summary);
1464    md
1465}
1466
1467/// Generate a v2 Markdown handoff document from merged summaries.
1468pub fn generate_merged_handoff_markdown_v2(merged: &MergedHandoff) -> String {
1469    let mut md = String::new();
1470    md.push_str("# Merged Session Handoff\n\n");
1471    md.push_str(&format!(
1472        "**Sessions:** {} | **Total Duration:** {}\n\n",
1473        merged.source_session_ids.len(),
1474        format_duration(merged.total_duration_seconds)
1475    ));
1476
1477    for (idx, summary) in merged.summaries.iter().enumerate() {
1478        md.push_str(&format!(
1479            "---\n\n## Session {} — {}\n\n",
1480            idx + 1,
1481            summary.source_session_id
1482        ));
1483        append_v2_markdown_sections(&mut md, summary);
1484        md.push('\n');
1485    }
1486
1487    md
1488}
1489
1490fn append_v2_markdown_sections(md: &mut String, summary: &HandoffSummary) {
1491    md.push_str("## Objective\n");
1492    md.push_str(&summary.objective);
1493    md.push_str("\n\n");
1494
1495    md.push_str("## Current State\n");
1496    md.push_str(&format!(
1497        "- **Tool:** {} ({})\n- **Duration:** {}\n- **Messages:** {} | Tool calls: {} | Events: {}\n",
1498        summary.tool,
1499        summary.model,
1500        format_duration(summary.duration_seconds),
1501        summary.stats.message_count,
1502        summary.stats.tool_call_count,
1503        summary.stats.event_count
1504    ));
1505    if !summary.execution_contract.done_definition.is_empty() {
1506        md.push_str("- **Done:**\n");
1507        for done in &summary.execution_contract.done_definition {
1508            md.push_str(&format!("  - {done}\n"));
1509        }
1510    }
1511    if !summary.execution_contract.ordered_steps.is_empty() {
1512        md.push_str("- **Execution Timeline (ordered):**\n");
1513        for step in &summary.execution_contract.ordered_steps {
1514            let started = step.started_at.as_deref().unwrap_or("?");
1515            let completed = step.completed_at.as_deref().unwrap_or("-");
1516            if step.depends_on.is_empty() {
1517                md.push_str(&format!(
1518                    "  - [{}] `{}` [{}] status={} start={} done={}\n",
1519                    step.sequence,
1520                    step.title,
1521                    step.work_package_id,
1522                    step.status,
1523                    started,
1524                    completed
1525                ));
1526            } else {
1527                md.push_str(&format!(
1528                    "  - [{}] `{}` [{}] status={} start={} done={} deps=[{}]\n",
1529                    step.sequence,
1530                    step.title,
1531                    step.work_package_id,
1532                    step.status,
1533                    started,
1534                    completed,
1535                    step.depends_on.join(", ")
1536                ));
1537            }
1538        }
1539    }
1540    md.push('\n');
1541
1542    md.push_str("## Next Actions (ordered)\n");
1543    if summary.execution_contract.next_actions.is_empty() {
1544        md.push_str("_(none)_\n");
1545    } else {
1546        for (idx, action) in summary.execution_contract.next_actions.iter().enumerate() {
1547            md.push_str(&format!("{}. {}\n", idx + 1, action));
1548        }
1549    }
1550    if !summary.execution_contract.parallel_actions.is_empty() {
1551        md.push_str("\nParallelizable Work Packages:\n");
1552        for action in &summary.execution_contract.parallel_actions {
1553            md.push_str(&format!("- {action}\n"));
1554        }
1555    }
1556    md.push('\n');
1557
1558    md.push_str("## Verification\n");
1559    if summary.verification.checks_run.is_empty() {
1560        md.push_str("- checks_run: _(none)_\n");
1561    } else {
1562        for check in &summary.verification.checks_run {
1563            let code = check
1564                .exit_code
1565                .map(|c| c.to_string())
1566                .unwrap_or_else(|| "?".to_string());
1567            md.push_str(&format!(
1568                "- [{}] `{}` (exit: {}, event: {})\n",
1569                check.status, check.command, code, check.event_id
1570            ));
1571        }
1572    }
1573    if !summary.verification.required_checks_missing.is_empty() {
1574        md.push_str("- required_checks_missing:\n");
1575        for item in &summary.verification.required_checks_missing {
1576            md.push_str(&format!("  - {item}\n"));
1577        }
1578    }
1579    md.push('\n');
1580
1581    md.push_str("## Blockers / Decisions\n");
1582    if summary.uncertainty.decision_required.is_empty()
1583        && summary.uncertainty.open_questions.is_empty()
1584    {
1585        md.push_str("_(none)_\n");
1586    } else {
1587        for item in &summary.uncertainty.decision_required {
1588            md.push_str(&format!("- {item}\n"));
1589        }
1590        if !summary.uncertainty.open_questions.is_empty() {
1591            md.push_str("- open_questions:\n");
1592            for item in &summary.uncertainty.open_questions {
1593                md.push_str(&format!("  - {item}\n"));
1594            }
1595        }
1596    }
1597    md.push('\n');
1598
1599    md.push_str("## Evidence Index\n");
1600    if summary.evidence.is_empty() {
1601        md.push_str("_(none)_\n");
1602    } else {
1603        for ev in &summary.evidence {
1604            md.push_str(&format!(
1605                "- `{}` {} ({}, {}, {})\n",
1606                ev.id, ev.claim, ev.event_id, ev.source_type, ev.timestamp
1607            ));
1608        }
1609    }
1610    md.push('\n');
1611
1612    md.push_str("## Conversations\n");
1613    if summary.key_conversations.is_empty() {
1614        md.push_str("_(none)_\n");
1615    } else {
1616        for (idx, conv) in summary.key_conversations.iter().enumerate() {
1617            md.push_str(&format!(
1618                "### {}. User\n{}\n\n### {}. Agent\n{}\n\n",
1619                idx + 1,
1620                truncate_str(&conv.user, 300),
1621                idx + 1,
1622                truncate_str(&conv.agent, 300)
1623            ));
1624        }
1625    }
1626
1627    md.push_str("## User Messages\n");
1628    if summary.user_messages.is_empty() {
1629        md.push_str("_(none)_\n");
1630    } else {
1631        for (idx, msg) in summary.user_messages.iter().enumerate() {
1632            md.push_str(&format!("{}. {}\n", idx + 1, truncate_str(msg, 150)));
1633        }
1634    }
1635}
1636
1637/// Generate a Markdown handoff document from a single session summary.
1638pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String {
1639    const MAX_TASK_SUMMARIES_DISPLAY: usize = 5;
1640    let mut md = String::new();
1641
1642    md.push_str("# Session Handoff\n\n");
1643
1644    // Objective
1645    md.push_str("## Objective\n");
1646    md.push_str(&summary.objective);
1647    md.push_str("\n\n");
1648
1649    // Summary
1650    md.push_str("## Summary\n");
1651    md.push_str(&format!(
1652        "- **Tool:** {} ({})\n",
1653        summary.tool, summary.model
1654    ));
1655    md.push_str(&format!(
1656        "- **Duration:** {}\n",
1657        format_duration(summary.duration_seconds)
1658    ));
1659    md.push_str(&format!(
1660        "- **Messages:** {} | Tool calls: {} | Events: {}\n",
1661        summary.stats.message_count, summary.stats.tool_call_count, summary.stats.event_count
1662    ));
1663    md.push('\n');
1664
1665    if !summary.task_summaries.is_empty() {
1666        md.push_str("## Task Summaries\n");
1667        for (idx, task_summary) in summary
1668            .task_summaries
1669            .iter()
1670            .take(MAX_TASK_SUMMARIES_DISPLAY)
1671            .enumerate()
1672        {
1673            md.push_str(&format!("{}. {}\n", idx + 1, task_summary));
1674        }
1675        if summary.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY {
1676            md.push_str(&format!(
1677                "- ... and {} more\n",
1678                summary.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY
1679            ));
1680        }
1681        md.push('\n');
1682    }
1683
1684    // Files Modified
1685    if !summary.files_modified.is_empty() {
1686        md.push_str("## Files Modified\n");
1687        for fc in &summary.files_modified {
1688            md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
1689        }
1690        md.push('\n');
1691    }
1692
1693    // Files Read
1694    if !summary.files_read.is_empty() {
1695        md.push_str("## Files Read\n");
1696        for path in &summary.files_read {
1697            md.push_str(&format!("- `{path}`\n"));
1698        }
1699        md.push('\n');
1700    }
1701
1702    // Shell Commands
1703    if !summary.shell_commands.is_empty() {
1704        md.push_str("## Shell Commands\n");
1705        for cmd in &summary.shell_commands {
1706            let code_str = match cmd.exit_code {
1707                Some(c) => c.to_string(),
1708                None => "?".to_string(),
1709            };
1710            md.push_str(&format!(
1711                "- `{}` → {}\n",
1712                truncate_str(&cmd.command, 80),
1713                code_str
1714            ));
1715        }
1716        md.push('\n');
1717    }
1718
1719    // Errors
1720    if !summary.errors.is_empty() {
1721        md.push_str("## Errors\n");
1722        for err in &summary.errors {
1723            md.push_str(&format!("- {err}\n"));
1724        }
1725        md.push('\n');
1726    }
1727
1728    // Key Conversations (user + agent pairs)
1729    if !summary.key_conversations.is_empty() {
1730        md.push_str("## Key Conversations\n");
1731        for (i, conv) in summary.key_conversations.iter().enumerate() {
1732            md.push_str(&format!(
1733                "### {}. User\n{}\n\n### {}. Agent\n{}\n\n",
1734                i + 1,
1735                truncate_str(&conv.user, 300),
1736                i + 1,
1737                truncate_str(&conv.agent, 300),
1738            ));
1739        }
1740    }
1741
1742    // User Messages (fallback list)
1743    if summary.key_conversations.is_empty() && !summary.user_messages.is_empty() {
1744        md.push_str("## User Messages\n");
1745        for (i, msg) in summary.user_messages.iter().enumerate() {
1746            md.push_str(&format!("{}. {}\n", i + 1, truncate_str(msg, 150)));
1747        }
1748        md.push('\n');
1749    }
1750
1751    md
1752}
1753
1754/// Generate a Markdown handoff document from a merged multi-session handoff.
1755pub fn generate_merged_handoff_markdown(merged: &MergedHandoff) -> String {
1756    const MAX_TASK_SUMMARIES_DISPLAY: usize = 3;
1757    let mut md = String::new();
1758
1759    md.push_str("# Merged Session Handoff\n\n");
1760    md.push_str(&format!(
1761        "**Sessions:** {} | **Total Duration:** {}\n\n",
1762        merged.source_session_ids.len(),
1763        format_duration(merged.total_duration_seconds)
1764    ));
1765
1766    // Per-session summaries
1767    for (i, s) in merged.summaries.iter().enumerate() {
1768        md.push_str(&format!(
1769            "---\n\n## Session {} — {}\n\n",
1770            i + 1,
1771            s.source_session_id
1772        ));
1773        md.push_str(&format!("**Objective:** {}\n\n", s.objective));
1774        md.push_str(&format!(
1775            "- **Tool:** {} ({}) | **Duration:** {}\n",
1776            s.tool,
1777            s.model,
1778            format_duration(s.duration_seconds)
1779        ));
1780        md.push_str(&format!(
1781            "- **Messages:** {} | Tool calls: {} | Events: {}\n\n",
1782            s.stats.message_count, s.stats.tool_call_count, s.stats.event_count
1783        ));
1784
1785        if !s.task_summaries.is_empty() {
1786            md.push_str("### Task Summaries\n");
1787            for (j, task_summary) in s
1788                .task_summaries
1789                .iter()
1790                .take(MAX_TASK_SUMMARIES_DISPLAY)
1791                .enumerate()
1792            {
1793                md.push_str(&format!("{}. {}\n", j + 1, task_summary));
1794            }
1795            if s.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY {
1796                md.push_str(&format!(
1797                    "- ... and {} more\n",
1798                    s.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY
1799                ));
1800            }
1801            md.push('\n');
1802        }
1803
1804        // Key Conversations for this session
1805        if !s.key_conversations.is_empty() {
1806            md.push_str("### Conversations\n");
1807            for (j, conv) in s.key_conversations.iter().enumerate() {
1808                md.push_str(&format!(
1809                    "**{}. User:** {}\n\n**{}. Agent:** {}\n\n",
1810                    j + 1,
1811                    truncate_str(&conv.user, 200),
1812                    j + 1,
1813                    truncate_str(&conv.agent, 200),
1814                ));
1815            }
1816        }
1817    }
1818
1819    // Combined files
1820    md.push_str("---\n\n## All Files Modified\n");
1821    if merged.all_files_modified.is_empty() {
1822        md.push_str("_(none)_\n");
1823    } else {
1824        for fc in &merged.all_files_modified {
1825            md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action));
1826        }
1827    }
1828    md.push('\n');
1829
1830    if !merged.all_files_read.is_empty() {
1831        md.push_str("## All Files Read\n");
1832        for path in &merged.all_files_read {
1833            md.push_str(&format!("- `{path}`\n"));
1834        }
1835        md.push('\n');
1836    }
1837
1838    // Errors
1839    if !merged.total_errors.is_empty() {
1840        md.push_str("## All Errors\n");
1841        for err in &merged.total_errors {
1842            md.push_str(&format!("- {err}\n"));
1843        }
1844        md.push('\n');
1845    }
1846
1847    md
1848}
1849
1850// ─── Summary HAIL generation ─────────────────────────────────────────────────
1851
1852/// Generate a summary HAIL session from an original session.
1853///
1854/// Filters events to only include important ones and truncates content.
1855pub fn generate_handoff_hail(session: &Session) -> Session {
1856    let mut summary_session = Session {
1857        version: session.version.clone(),
1858        session_id: format!("handoff-{}", session.session_id),
1859        agent: session.agent.clone(),
1860        context: SessionContext {
1861            title: Some(format!(
1862                "Handoff: {}",
1863                session.context.title.as_deref().unwrap_or("(untitled)")
1864            )),
1865            description: session.context.description.clone(),
1866            tags: {
1867                let mut tags = session.context.tags.clone();
1868                if !tags.contains(&"handoff".to_string()) {
1869                    tags.push("handoff".to_string());
1870                }
1871                tags
1872            },
1873            created_at: session.context.created_at,
1874            updated_at: chrono::Utc::now(),
1875            related_session_ids: vec![session.session_id.clone()],
1876            attributes: HashMap::new(),
1877        },
1878        events: Vec::new(),
1879        stats: session.stats.clone(),
1880    };
1881
1882    for event in &session.events {
1883        let keep = matches!(
1884            &event.event_type,
1885            EventType::UserMessage
1886                | EventType::AgentMessage
1887                | EventType::FileEdit { .. }
1888                | EventType::FileCreate { .. }
1889                | EventType::FileDelete { .. }
1890                | EventType::TaskStart { .. }
1891                | EventType::TaskEnd { .. }
1892        ) || matches!(&event.event_type, EventType::ShellCommand { exit_code, .. } if *exit_code != Some(0));
1893
1894        if !keep {
1895            continue;
1896        }
1897
1898        // Truncate content blocks
1899        let truncated_blocks: Vec<ContentBlock> = event
1900            .content
1901            .blocks
1902            .iter()
1903            .map(|block| match block {
1904                ContentBlock::Text { text } => ContentBlock::Text {
1905                    text: truncate_str(text, 300),
1906                },
1907                ContentBlock::Code {
1908                    code,
1909                    language,
1910                    start_line,
1911                } => ContentBlock::Code {
1912                    code: truncate_str(code, 300),
1913                    language: language.clone(),
1914                    start_line: *start_line,
1915                },
1916                other => other.clone(),
1917            })
1918            .collect();
1919
1920        summary_session.events.push(Event {
1921            event_id: event.event_id.clone(),
1922            timestamp: event.timestamp,
1923            event_type: event.event_type.clone(),
1924            task_id: event.task_id.clone(),
1925            content: Content {
1926                blocks: truncated_blocks,
1927            },
1928            duration_ms: event.duration_ms,
1929            attributes: HashMap::new(), // strip detailed attributes
1930        });
1931    }
1932
1933    // Recompute stats for the filtered events
1934    summary_session.recompute_stats();
1935
1936    summary_session
1937}
1938
1939// ─── Helpers ─────────────────────────────────────────────────────────────────
1940
1941fn extract_first_user_text(session: &Session) -> Option<String> {
1942    crate::extract::extract_first_user_text(session)
1943}
1944
1945fn extract_objective(session: &Session) -> String {
1946    if let Some(user_text) = extract_first_user_text(session).filter(|t| !t.trim().is_empty()) {
1947        return truncate_str(&collapse_whitespace(&user_text), 200);
1948    }
1949
1950    if let Some(task_title) = session
1951        .events
1952        .iter()
1953        .find_map(|event| match &event.event_type {
1954            EventType::TaskStart { title: Some(title) } => {
1955                let title = title.trim();
1956                if title.is_empty() {
1957                    None
1958                } else {
1959                    Some(title.to_string())
1960                }
1961            }
1962            _ => None,
1963        })
1964    {
1965        return truncate_str(&collapse_whitespace(&task_title), 200);
1966    }
1967
1968    if let Some(task_summary) = session
1969        .events
1970        .iter()
1971        .find_map(|event| match &event.event_type {
1972            EventType::TaskEnd {
1973                summary: Some(summary),
1974            } => {
1975                let summary = summary.trim();
1976                if summary.is_empty() {
1977                    None
1978                } else {
1979                    Some(summary.to_string())
1980                }
1981            }
1982            _ => None,
1983        })
1984    {
1985        return truncate_str(&collapse_whitespace(&task_summary), 200);
1986    }
1987
1988    if let Some(title) = session.context.title.as_deref().map(str::trim) {
1989        if !title.is_empty() {
1990            return truncate_str(&collapse_whitespace(title), 200);
1991        }
1992    }
1993
1994    "(objective unavailable)".to_string()
1995}
1996
1997fn extract_text_from_event(event: &Event) -> Option<String> {
1998    for block in &event.content.blocks {
1999        if let ContentBlock::Text { text } = block {
2000            let trimmed = text.trim();
2001            if !trimmed.is_empty() {
2002                return Some(trimmed.to_string());
2003            }
2004        }
2005    }
2006    None
2007}
2008
2009fn collapse_whitespace(input: &str) -> String {
2010    input.split_whitespace().collect::<Vec<_>>().join(" ")
2011}
2012
2013/// Format seconds into a human-readable duration string.
2014pub fn format_duration(seconds: u64) -> String {
2015    if seconds < 60 {
2016        format!("{seconds}s")
2017    } else if seconds < 3600 {
2018        let m = seconds / 60;
2019        let s = seconds % 60;
2020        format!("{m}m {s}s")
2021    } else {
2022        let h = seconds / 3600;
2023        let m = (seconds % 3600) / 60;
2024        let s = seconds % 60;
2025        format!("{h}h {m}m {s}s")
2026    }
2027}
2028
2029// ─── Tests ───────────────────────────────────────────────────────────────────
2030
2031#[cfg(test)]
2032mod tests {
2033    use super::*;
2034    use crate::{testing, Agent};
2035
2036    fn make_agent() -> Agent {
2037        testing::agent()
2038    }
2039
2040    fn make_event(event_type: EventType, text: &str) -> Event {
2041        testing::event(event_type, text)
2042    }
2043
2044    #[test]
2045    fn test_format_duration() {
2046        assert_eq!(format_duration(0), "0s");
2047        assert_eq!(format_duration(45), "45s");
2048        assert_eq!(format_duration(90), "1m 30s");
2049        assert_eq!(format_duration(750), "12m 30s");
2050        assert_eq!(format_duration(3661), "1h 1m 1s");
2051    }
2052
2053    #[test]
2054    fn test_handoff_summary_from_session() {
2055        let mut session = Session::new("test-id".to_string(), make_agent());
2056        session.stats = Stats {
2057            event_count: 10,
2058            message_count: 3,
2059            tool_call_count: 5,
2060            duration_seconds: 750,
2061            ..Default::default()
2062        };
2063        session
2064            .events
2065            .push(make_event(EventType::UserMessage, "Fix the build error"));
2066        session
2067            .events
2068            .push(make_event(EventType::AgentMessage, "I'll fix it now"));
2069        session.events.push(make_event(
2070            EventType::FileEdit {
2071                path: "src/main.rs".to_string(),
2072                diff: None,
2073            },
2074            "",
2075        ));
2076        session.events.push(make_event(
2077            EventType::FileRead {
2078                path: "Cargo.toml".to_string(),
2079            },
2080            "",
2081        ));
2082        session.events.push(make_event(
2083            EventType::ShellCommand {
2084                command: "cargo build".to_string(),
2085                exit_code: Some(0),
2086            },
2087            "",
2088        ));
2089        session.events.push(make_event(
2090            EventType::TaskEnd {
2091                summary: Some("Build now passes in local env".to_string()),
2092            },
2093            "",
2094        ));
2095
2096        let summary = HandoffSummary::from_session(&session);
2097
2098        assert_eq!(summary.source_session_id, "test-id");
2099        assert_eq!(summary.objective, "Fix the build error");
2100        assert_eq!(summary.files_modified.len(), 1);
2101        assert_eq!(summary.files_modified[0].path, "src/main.rs");
2102        assert_eq!(summary.files_modified[0].action, "edited");
2103        assert_eq!(summary.files_read, vec!["Cargo.toml"]);
2104        assert_eq!(summary.shell_commands.len(), 1);
2105        assert_eq!(
2106            summary.task_summaries,
2107            vec!["Build now passes in local env".to_string()]
2108        );
2109        assert_eq!(summary.key_conversations.len(), 1);
2110        assert_eq!(summary.key_conversations[0].user, "Fix the build error");
2111        assert_eq!(summary.key_conversations[0].agent, "I'll fix it now");
2112    }
2113
2114    #[test]
2115    fn test_handoff_objective_falls_back_to_task_title() {
2116        let mut session = Session::new("task-title-fallback".to_string(), make_agent());
2117        session.context.title = Some("session-019c-example.jsonl".to_string());
2118        session.events.push(make_event(
2119            EventType::TaskStart {
2120                title: Some("Refactor auth middleware for oauth callback".to_string()),
2121            },
2122            "",
2123        ));
2124
2125        let summary = HandoffSummary::from_session(&session);
2126        assert_eq!(
2127            summary.objective,
2128            "Refactor auth middleware for oauth callback"
2129        );
2130    }
2131
2132    #[test]
2133    fn test_handoff_task_summaries_are_deduplicated() {
2134        let mut session = Session::new("task-summary-dedupe".to_string(), make_agent());
2135        session.events.push(make_event(
2136            EventType::TaskEnd {
2137                summary: Some("Add worker profile guard".to_string()),
2138            },
2139            "",
2140        ));
2141        session.events.push(make_event(
2142            EventType::TaskEnd {
2143                summary: Some(" ".to_string()),
2144            },
2145            "",
2146        ));
2147        session.events.push(make_event(
2148            EventType::TaskEnd {
2149                summary: Some("Add worker profile guard".to_string()),
2150            },
2151            "",
2152        ));
2153        session.events.push(make_event(
2154            EventType::TaskEnd {
2155                summary: Some("Hide teams nav for worker profile".to_string()),
2156            },
2157            "",
2158        ));
2159
2160        let summary = HandoffSummary::from_session(&session);
2161        assert_eq!(
2162            summary.task_summaries,
2163            vec![
2164                "Add worker profile guard".to_string(),
2165                "Hide teams nav for worker profile".to_string()
2166            ]
2167        );
2168    }
2169
2170    #[test]
2171    fn test_files_read_excludes_modified() {
2172        let mut session = Session::new("test-id".to_string(), make_agent());
2173        session
2174            .events
2175            .push(make_event(EventType::UserMessage, "test"));
2176        session.events.push(make_event(
2177            EventType::FileRead {
2178                path: "src/main.rs".to_string(),
2179            },
2180            "",
2181        ));
2182        session.events.push(make_event(
2183            EventType::FileEdit {
2184                path: "src/main.rs".to_string(),
2185                diff: None,
2186            },
2187            "",
2188        ));
2189        session.events.push(make_event(
2190            EventType::FileRead {
2191                path: "README.md".to_string(),
2192            },
2193            "",
2194        ));
2195
2196        let summary = HandoffSummary::from_session(&session);
2197        assert_eq!(summary.files_read, vec!["README.md"]);
2198        assert_eq!(summary.files_modified.len(), 1);
2199    }
2200
2201    #[test]
2202    fn test_file_create_not_overwritten_by_edit() {
2203        let mut session = Session::new("test-id".to_string(), make_agent());
2204        session
2205            .events
2206            .push(make_event(EventType::UserMessage, "test"));
2207        session.events.push(make_event(
2208            EventType::FileCreate {
2209                path: "new_file.rs".to_string(),
2210            },
2211            "",
2212        ));
2213        session.events.push(make_event(
2214            EventType::FileEdit {
2215                path: "new_file.rs".to_string(),
2216                diff: None,
2217            },
2218            "",
2219        ));
2220
2221        let summary = HandoffSummary::from_session(&session);
2222        assert_eq!(summary.files_modified[0].action, "created");
2223    }
2224
2225    #[test]
2226    fn test_shell_error_captured() {
2227        let mut session = Session::new("test-id".to_string(), make_agent());
2228        session
2229            .events
2230            .push(make_event(EventType::UserMessage, "test"));
2231        session.events.push(make_event(
2232            EventType::ShellCommand {
2233                command: "cargo test".to_string(),
2234                exit_code: Some(1),
2235            },
2236            "",
2237        ));
2238
2239        let summary = HandoffSummary::from_session(&session);
2240        assert_eq!(summary.errors.len(), 1);
2241        assert!(summary.errors[0].contains("cargo test"));
2242    }
2243
2244    #[test]
2245    fn test_generate_handoff_markdown() {
2246        let mut session = Session::new("test-id".to_string(), make_agent());
2247        session.stats = Stats {
2248            event_count: 10,
2249            message_count: 3,
2250            tool_call_count: 5,
2251            duration_seconds: 750,
2252            ..Default::default()
2253        };
2254        session
2255            .events
2256            .push(make_event(EventType::UserMessage, "Fix the build error"));
2257        session
2258            .events
2259            .push(make_event(EventType::AgentMessage, "I'll fix it now"));
2260        session.events.push(make_event(
2261            EventType::FileEdit {
2262                path: "src/main.rs".to_string(),
2263                diff: None,
2264            },
2265            "",
2266        ));
2267        session.events.push(make_event(
2268            EventType::ShellCommand {
2269                command: "cargo build".to_string(),
2270                exit_code: Some(0),
2271            },
2272            "",
2273        ));
2274        session.events.push(make_event(
2275            EventType::TaskEnd {
2276                summary: Some("Compile error fixed by updating trait bounds".to_string()),
2277            },
2278            "",
2279        ));
2280
2281        let summary = HandoffSummary::from_session(&session);
2282        let md = generate_handoff_markdown(&summary);
2283
2284        assert!(md.contains("# Session Handoff"));
2285        assert!(md.contains("Fix the build error"));
2286        assert!(md.contains("claude-code (claude-opus-4-6)"));
2287        assert!(md.contains("12m 30s"));
2288        assert!(md.contains("## Task Summaries"));
2289        assert!(md.contains("Compile error fixed by updating trait bounds"));
2290        assert!(md.contains("`src/main.rs` (edited)"));
2291        assert!(md.contains("`cargo build` → 0"));
2292        assert!(md.contains("## Key Conversations"));
2293    }
2294
2295    #[test]
2296    fn test_merge_summaries() {
2297        let mut s1 = Session::new("session-a".to_string(), make_agent());
2298        s1.stats.duration_seconds = 100;
2299        s1.events.push(make_event(EventType::UserMessage, "task A"));
2300        s1.events.push(make_event(
2301            EventType::FileEdit {
2302                path: "a.rs".to_string(),
2303                diff: None,
2304            },
2305            "",
2306        ));
2307
2308        let mut s2 = Session::new("session-b".to_string(), make_agent());
2309        s2.stats.duration_seconds = 200;
2310        s2.events.push(make_event(EventType::UserMessage, "task B"));
2311        s2.events.push(make_event(
2312            EventType::FileEdit {
2313                path: "b.rs".to_string(),
2314                diff: None,
2315            },
2316            "",
2317        ));
2318
2319        let sum1 = HandoffSummary::from_session(&s1);
2320        let sum2 = HandoffSummary::from_session(&s2);
2321        let merged = merge_summaries(&[sum1, sum2]);
2322
2323        assert_eq!(merged.source_session_ids.len(), 2);
2324        assert_eq!(merged.total_duration_seconds, 300);
2325        assert_eq!(merged.all_files_modified.len(), 2);
2326    }
2327
2328    #[test]
2329    fn test_generate_handoff_hail() {
2330        let mut session = Session::new("test-id".to_string(), make_agent());
2331        session
2332            .events
2333            .push(make_event(EventType::UserMessage, "Hello"));
2334        session
2335            .events
2336            .push(make_event(EventType::AgentMessage, "Hi there"));
2337        session.events.push(make_event(
2338            EventType::FileRead {
2339                path: "foo.rs".to_string(),
2340            },
2341            "",
2342        ));
2343        session.events.push(make_event(
2344            EventType::FileEdit {
2345                path: "foo.rs".to_string(),
2346                diff: Some("+added line".to_string()),
2347            },
2348            "",
2349        ));
2350        session.events.push(make_event(
2351            EventType::ShellCommand {
2352                command: "cargo build".to_string(),
2353                exit_code: Some(0),
2354            },
2355            "",
2356        ));
2357
2358        let hail = generate_handoff_hail(&session);
2359
2360        assert!(hail.session_id.starts_with("handoff-"));
2361        assert_eq!(hail.context.related_session_ids, vec!["test-id"]);
2362        assert!(hail.context.tags.contains(&"handoff".to_string()));
2363        // FileRead and successful ShellCommand should be filtered out
2364        assert_eq!(hail.events.len(), 3); // UserMessage, AgentMessage, FileEdit
2365                                          // Verify HAIL roundtrip
2366        let jsonl = hail.to_jsonl().unwrap();
2367        let parsed = Session::from_jsonl(&jsonl).unwrap();
2368        assert_eq!(parsed.session_id, hail.session_id);
2369    }
2370
2371    #[test]
2372    fn test_generate_handoff_markdown_v2_section_order() {
2373        let mut session = Session::new("v2-sections".to_string(), make_agent());
2374        session
2375            .events
2376            .push(make_event(EventType::UserMessage, "Implement handoff v2"));
2377        session.events.push(make_event(
2378            EventType::FileEdit {
2379                path: "crates/core/src/handoff.rs".to_string(),
2380                diff: None,
2381            },
2382            "",
2383        ));
2384        session.events.push(make_event(
2385            EventType::ShellCommand {
2386                command: "cargo test".to_string(),
2387                exit_code: Some(0),
2388            },
2389            "",
2390        ));
2391
2392        let summary = HandoffSummary::from_session(&session);
2393        let md = generate_handoff_markdown_v2(&summary);
2394
2395        let order = [
2396            "## Objective",
2397            "## Current State",
2398            "## Next Actions (ordered)",
2399            "## Verification",
2400            "## Blockers / Decisions",
2401            "## Evidence Index",
2402            "## Conversations",
2403            "## User Messages",
2404        ];
2405
2406        let mut last_idx = 0usize;
2407        for section in order {
2408            let idx = md.find(section).unwrap();
2409            assert!(
2410                idx >= last_idx,
2411                "section order mismatch for {section}: idx={idx}, last={last_idx}"
2412            );
2413            last_idx = idx;
2414        }
2415    }
2416
2417    #[test]
2418    fn test_execution_contract_and_verification_from_failed_command() {
2419        let mut session = Session::new("failed-check".to_string(), make_agent());
2420        session
2421            .events
2422            .push(make_event(EventType::UserMessage, "Fix failing tests"));
2423        session.events.push(make_event(
2424            EventType::FileEdit {
2425                path: "src/lib.rs".to_string(),
2426                diff: None,
2427            },
2428            "",
2429        ));
2430        session.events.push(make_event(
2431            EventType::ShellCommand {
2432                command: "cargo test".to_string(),
2433                exit_code: Some(1),
2434            },
2435            "",
2436        ));
2437
2438        let summary = HandoffSummary::from_session(&session);
2439        assert!(summary
2440            .verification
2441            .checks_failed
2442            .contains(&"cargo test".to_string()));
2443        assert!(summary
2444            .execution_contract
2445            .next_actions
2446            .iter()
2447            .any(|action| action.contains("cargo test")));
2448        assert_eq!(
2449            summary.execution_contract.ordered_commands.first(),
2450            Some(&"cargo test".to_string())
2451        );
2452        assert!(summary.execution_contract.parallel_actions.is_empty());
2453        assert!(summary.execution_contract.rollback_hint.is_none());
2454        assert!(summary
2455            .execution_contract
2456            .rollback_hint_missing_reason
2457            .is_some());
2458        assert!(summary
2459            .execution_contract
2460            .rollback_hint_undefined_reason
2461            .is_some());
2462    }
2463
2464    #[test]
2465    fn test_validate_handoff_summary_flags_missing_objective() {
2466        let session = Session::new("missing-objective".to_string(), make_agent());
2467        let summary = HandoffSummary::from_session(&session);
2468        assert!(summary.objective_undefined_reason.is_some());
2469        assert!(summary
2470            .undefined_fields
2471            .iter()
2472            .any(|f| f.path == "objective"));
2473        let report = validate_handoff_summary(&summary);
2474
2475        assert!(!report.passed);
2476        assert!(report
2477            .findings
2478            .iter()
2479            .any(|f| f.code == "objective_missing"));
2480    }
2481
2482    #[test]
2483    fn test_validate_handoff_summary_flags_cycle() {
2484        let mut session = Session::new("cycle-case".to_string(), make_agent());
2485        session
2486            .events
2487            .push(make_event(EventType::UserMessage, "test"));
2488        let mut summary = HandoffSummary::from_session(&session);
2489        summary.work_packages = vec![
2490            WorkPackage {
2491                id: "a".to_string(),
2492                title: "A".to_string(),
2493                status: "pending".to_string(),
2494                sequence: 1,
2495                started_at: None,
2496                completed_at: None,
2497                outcome: None,
2498                depends_on: vec!["b".to_string()],
2499                files: Vec::new(),
2500                commands: Vec::new(),
2501                evidence_refs: Vec::new(),
2502            },
2503            WorkPackage {
2504                id: "b".to_string(),
2505                title: "B".to_string(),
2506                status: "pending".to_string(),
2507                sequence: 2,
2508                started_at: None,
2509                completed_at: None,
2510                outcome: None,
2511                depends_on: vec!["a".to_string()],
2512                files: Vec::new(),
2513                commands: Vec::new(),
2514                evidence_refs: Vec::new(),
2515            },
2516        ];
2517
2518        let report = validate_handoff_summary(&summary);
2519        assert!(report
2520            .findings
2521            .iter()
2522            .any(|f| f.code == "work_package_cycle"));
2523    }
2524
2525    #[test]
2526    fn test_validate_handoff_summary_requires_next_actions_for_failed_checks() {
2527        let mut session = Session::new("missing-next-action".to_string(), make_agent());
2528        session
2529            .events
2530            .push(make_event(EventType::UserMessage, "test"));
2531        let mut summary = HandoffSummary::from_session(&session);
2532        summary.verification.checks_run = vec![CheckRun {
2533            command: "cargo test".to_string(),
2534            status: "failed".to_string(),
2535            exit_code: Some(1),
2536            event_id: "evt-1".to_string(),
2537        }];
2538        summary.execution_contract.next_actions.clear();
2539
2540        let report = validate_handoff_summary(&summary);
2541        assert!(report
2542            .findings
2543            .iter()
2544            .any(|f| f.code == "next_actions_missing"));
2545    }
2546
2547    #[test]
2548    fn test_validate_handoff_summary_flags_missing_objective_evidence() {
2549        let mut session = Session::new("missing-objective-evidence".to_string(), make_agent());
2550        session
2551            .events
2552            .push(make_event(EventType::UserMessage, "keep objective"));
2553        let mut summary = HandoffSummary::from_session(&session);
2554        summary.evidence = vec![EvidenceRef {
2555            id: "evidence-1".to_string(),
2556            claim: "task_done: something".to_string(),
2557            event_id: "evt".to_string(),
2558            timestamp: "2026-02-01T00:00:00Z".to_string(),
2559            source_type: "TaskEnd".to_string(),
2560        }];
2561
2562        let report = validate_handoff_summary(&summary);
2563        assert!(report
2564            .findings
2565            .iter()
2566            .any(|f| f.code == "objective_evidence_missing"));
2567    }
2568
2569    #[test]
2570    fn test_execution_contract_includes_parallel_actions_for_independent_work_packages() {
2571        let mut session = Session::new("parallel-actions".to_string(), make_agent());
2572        session.events.push(make_event(
2573            EventType::UserMessage,
2574            "Refactor two independent modules",
2575        ));
2576
2577        let mut a_start = make_event(
2578            EventType::TaskStart {
2579                title: Some("Refactor auth".to_string()),
2580            },
2581            "",
2582        );
2583        a_start.task_id = Some("auth".to_string());
2584        session.events.push(a_start);
2585
2586        let mut a_edit = make_event(
2587            EventType::FileEdit {
2588                path: "src/auth.rs".to_string(),
2589                diff: None,
2590            },
2591            "",
2592        );
2593        a_edit.task_id = Some("auth".to_string());
2594        session.events.push(a_edit);
2595
2596        let mut b_start = make_event(
2597            EventType::TaskStart {
2598                title: Some("Refactor billing".to_string()),
2599            },
2600            "",
2601        );
2602        b_start.task_id = Some("billing".to_string());
2603        session.events.push(b_start);
2604
2605        let mut b_edit = make_event(
2606            EventType::FileEdit {
2607                path: "src/billing.rs".to_string(),
2608                diff: None,
2609            },
2610            "",
2611        );
2612        b_edit.task_id = Some("billing".to_string());
2613        session.events.push(b_edit);
2614
2615        let summary = HandoffSummary::from_session(&session);
2616        assert!(summary
2617            .execution_contract
2618            .parallel_actions
2619            .iter()
2620            .any(|action| action.contains("auth")));
2621        assert!(summary
2622            .execution_contract
2623            .parallel_actions
2624            .iter()
2625            .any(|action| action.contains("billing")));
2626        let md = generate_handoff_markdown_v2(&summary);
2627        assert!(md.contains("Parallelizable Work Packages"));
2628    }
2629
2630    #[test]
2631    fn test_done_definition_prefers_material_signals() {
2632        let mut session = Session::new("material-signals".to_string(), make_agent());
2633        session
2634            .events
2635            .push(make_event(EventType::UserMessage, "Implement feature X"));
2636        session.events.push(make_event(
2637            EventType::FileEdit {
2638                path: "src/lib.rs".to_string(),
2639                diff: None,
2640            },
2641            "",
2642        ));
2643        session.events.push(make_event(
2644            EventType::ShellCommand {
2645                command: "cargo test".to_string(),
2646                exit_code: Some(0),
2647            },
2648            "",
2649        ));
2650
2651        let summary = HandoffSummary::from_session(&session);
2652        assert!(summary
2653            .execution_contract
2654            .done_definition
2655            .iter()
2656            .any(|item| item.contains("Verification passed: `cargo test`")));
2657        assert!(summary
2658            .execution_contract
2659            .done_definition
2660            .iter()
2661            .any(|item| item.contains("Changed 1 file(s): `src/lib.rs`")));
2662        assert!(summary
2663            .execution_contract
2664            .ordered_steps
2665            .iter()
2666            .any(|step| step.work_package_id == "main"));
2667    }
2668
2669    #[test]
2670    fn test_ordered_steps_keep_temporal_and_task_context() {
2671        let mut session = Session::new("ordered-steps".to_string(), make_agent());
2672        session
2673            .events
2674            .push(make_event(EventType::UserMessage, "Process two tasks"));
2675
2676        let mut t1_start = make_event(
2677            EventType::TaskStart {
2678                title: Some("Prepare migration".to_string()),
2679            },
2680            "",
2681        );
2682        t1_start.task_id = Some("task-1".to_string());
2683        session.events.push(t1_start);
2684
2685        let mut t1_end = make_event(
2686            EventType::TaskEnd {
2687                summary: Some("Migration script prepared".to_string()),
2688            },
2689            "",
2690        );
2691        t1_end.task_id = Some("task-1".to_string());
2692        session.events.push(t1_end);
2693
2694        let mut t2_start = make_event(
2695            EventType::TaskStart {
2696                title: Some("Run verification".to_string()),
2697            },
2698            "",
2699        );
2700        t2_start.task_id = Some("task-2".to_string());
2701        session.events.push(t2_start);
2702
2703        let mut t2_cmd = make_event(
2704            EventType::ShellCommand {
2705                command: "cargo test".to_string(),
2706                exit_code: Some(0),
2707            },
2708            "",
2709        );
2710        t2_cmd.task_id = Some("task-2".to_string());
2711        session.events.push(t2_cmd);
2712
2713        let summary = HandoffSummary::from_session(&session);
2714        let steps = &summary.execution_contract.ordered_steps;
2715        assert_eq!(steps.len(), 2);
2716        assert!(steps[0].sequence < steps[1].sequence);
2717        assert_eq!(steps[0].work_package_id, "task-1");
2718        assert_eq!(steps[1].work_package_id, "task-2");
2719        assert!(steps[0].completed_at.is_some());
2720        assert!(summary
2721            .work_packages
2722            .iter()
2723            .find(|pkg| pkg.id == "task-1")
2724            .and_then(|pkg| pkg.outcome.as_deref())
2725            .is_some());
2726    }
2727
2728    #[test]
2729    fn test_validate_handoff_summary_flags_inconsistent_ordered_steps() {
2730        let mut session = Session::new("invalid-ordered-steps".to_string(), make_agent());
2731        session
2732            .events
2733            .push(make_event(EventType::UserMessage, "test ordered steps"));
2734        let mut summary = HandoffSummary::from_session(&session);
2735        summary.work_packages = vec![WorkPackage {
2736            id: "main".to_string(),
2737            title: "Main flow".to_string(),
2738            status: "completed".to_string(),
2739            sequence: 1,
2740            started_at: Some("2026-02-19T00:00:00Z".to_string()),
2741            completed_at: Some("2026-02-19T00:01:00Z".to_string()),
2742            outcome: Some("done".to_string()),
2743            depends_on: Vec::new(),
2744            files: vec!["src/lib.rs".to_string()],
2745            commands: Vec::new(),
2746            evidence_refs: Vec::new(),
2747        }];
2748        summary.execution_contract.ordered_steps = vec![OrderedStep {
2749            sequence: 1,
2750            work_package_id: "missing".to_string(),
2751            title: "missing".to_string(),
2752            status: "completed".to_string(),
2753            depends_on: Vec::new(),
2754            started_at: Some("2026-02-19T00:00:00Z".to_string()),
2755            completed_at: Some("2026-02-19T00:01:00Z".to_string()),
2756            evidence_refs: Vec::new(),
2757        }];
2758
2759        let report = validate_handoff_summary(&summary);
2760        assert!(report
2761            .findings
2762            .iter()
2763            .any(|finding| finding.code == "ordered_steps_inconsistent"));
2764    }
2765
2766    #[test]
2767    fn test_message_and_conversation_collections_are_condensed() {
2768        let mut session = Session::new("condense".to_string(), make_agent());
2769
2770        for i in 0..24 {
2771            session
2772                .events
2773                .push(make_event(EventType::UserMessage, &format!("user-{i}")));
2774            session
2775                .events
2776                .push(make_event(EventType::AgentMessage, &format!("agent-{i}")));
2777        }
2778
2779        let summary = HandoffSummary::from_session(&session);
2780        assert_eq!(summary.user_messages.len(), MAX_USER_MESSAGES);
2781        assert_eq!(
2782            summary.user_messages.first().map(String::as_str),
2783            Some("user-0")
2784        );
2785        assert_eq!(
2786            summary.user_messages.last().map(String::as_str),
2787            Some("user-23")
2788        );
2789
2790        assert_eq!(summary.key_conversations.len(), MAX_KEY_CONVERSATIONS);
2791        assert_eq!(
2792            summary
2793                .key_conversations
2794                .first()
2795                .map(|conv| conv.user.as_str()),
2796            Some("user-0")
2797        );
2798        assert_eq!(
2799            summary
2800                .key_conversations
2801                .last()
2802                .map(|conv| conv.user.as_str()),
2803            Some("user-23")
2804        );
2805    }
2806}