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