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