1use std::collections::{BTreeMap, HashMap, HashSet};
7
8use crate::extract::truncate_str;
9use crate::{Content, ContentBlock, Event, EventType, Session, SessionContext, Stats};
10
11#[derive(Debug, Clone, serde::Serialize)]
15pub struct FileChange {
16 pub path: String,
17 pub action: &'static str,
19}
20
21#[derive(Debug, Clone, serde::Serialize)]
23pub struct ShellCmd {
24 pub command: String,
25 pub exit_code: Option<i32>,
26}
27
28#[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#[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#[derive(Debug, Clone, serde::Serialize)]
147pub struct MergedHandoff {
148 pub source_session_ids: Vec<String>,
149 pub summaries: Vec<HandoffSummary>,
150 pub all_files_modified: Vec<FileChange>,
152 pub all_files_read: Vec<String>,
154 pub total_duration_seconds: u64,
155 pub total_errors: Vec<String>,
156}
157
158const 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 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
223fn 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
250fn 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
280fn 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
1161fn 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
1217pub 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 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
1457pub 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
1467pub 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
1637pub 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 md.push_str("## Objective\n");
1646 md.push_str(&summary.objective);
1647 md.push_str("\n\n");
1648
1649 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 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 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 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 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 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 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
1754pub 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 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 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 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 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
1850pub 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 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(), });
1931 }
1932
1933 summary_session.recompute_stats();
1935
1936 summary_session
1937}
1938
1939fn 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
2013pub 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#[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 assert_eq!(hail.events.len(), 3); 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}