1pub mod git;
2pub mod prompt;
3pub mod provider;
4pub mod text;
5pub mod types;
6pub use prompt::{validate_summary_prompt_template, DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2};
7
8use crate::git::{GitSummaryContext, GitSummaryService, ShellGitCommandRunner};
9use crate::prompt::{
10 build_summary_prompt, classify_arch_layer, collect_file_changes, collect_timeline_snippets,
11 contains_auth_security_keyword, SummaryPromptConfig,
12};
13use crate::provider::{generate_summary, SemanticSummary};
14use crate::text::compact_summary_snippet;
15use crate::types::HailCompactFileChange;
16use opensession_core::trace::{Agent, ContentBlock, Event, EventType, Session};
17use opensession_runtime_config::{SummaryProvider, SummarySettings};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use std::collections::{BTreeMap, HashMap};
21use std::path::{Path, PathBuf};
22
23const MAX_TIMELINE_SNIPPETS: usize = 32;
24const MAX_FILE_CHANGE_ENTRIES: usize = 200;
25const MAX_DIFF_HUNKS_PER_FILE: usize = 10;
26const MAX_DIFF_LINES_PER_HUNK: usize = 40;
27const MAX_DIFF_FILES_PER_LAYER: usize = 80;
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "snake_case")]
31pub enum SummarySourceKind {
32 SessionSignals,
33 GitCommit,
34 GitWorkingTree,
35 Heuristic,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum SummaryGenerationKind {
41 Provider,
42 HeuristicFallback,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct DiffHunkNode {
47 pub header: String,
48 #[serde(default)]
49 pub lines: Vec<String>,
50 pub lines_added: u64,
51 pub lines_removed: u64,
52 #[serde(default)]
53 pub omitted_lines: u64,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct DiffFileNode {
58 pub path: String,
59 pub operation: String,
60 pub lines_added: u64,
61 pub lines_removed: u64,
62 #[serde(default)]
63 pub hunks: Vec<DiffHunkNode>,
64 #[serde(default)]
65 pub is_large: bool,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct DiffLayerNode {
70 pub layer: String,
71 pub file_count: usize,
72 pub lines_added: u64,
73 pub lines_removed: u64,
74 #[serde(default)]
75 pub files: Vec<DiffFileNode>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct SemanticSummaryArtifact {
80 pub summary: SemanticSummary,
81 pub source_kind: SummarySourceKind,
82 pub generation_kind: SummaryGenerationKind,
83 pub provider: SummaryProvider,
84 pub model: String,
85 pub prompt_fingerprint: String,
86 #[serde(default)]
87 pub diff_tree: Vec<DiffLayerNode>,
88 #[serde(default)]
89 pub source_details: HashMap<String, String>,
90 #[serde(default)]
91 pub error: Option<String>,
92}
93
94#[derive(Debug, Clone)]
95pub struct GitSummaryRequest {
96 pub repo_root: PathBuf,
97 pub commit: Option<String>,
98}
99
100impl GitSummaryRequest {
101 pub fn from_commit(repo_root: impl Into<PathBuf>, commit: impl Into<String>) -> Self {
102 Self {
103 repo_root: repo_root.into(),
104 commit: Some(commit.into()),
105 }
106 }
107
108 pub fn working_tree(repo_root: impl Into<PathBuf>) -> Self {
109 Self {
110 repo_root: repo_root.into(),
111 commit: None,
112 }
113 }
114}
115
116pub fn detect_summary_provider() -> Option<provider::LocalSummaryProfile> {
117 provider::detect_local_summary_profile()
118}
119
120pub async fn summarize_session(
121 session: &Session,
122 settings: &SummarySettings,
123 git_request: Option<&GitSummaryRequest>,
124) -> Result<SemanticSummaryArtifact, String> {
125 let timeline = collect_timeline_snippets(session, MAX_TIMELINE_SNIPPETS, default_event_snippet);
126 let files = collect_file_changes(session, MAX_FILE_CHANGE_ENTRIES);
127
128 let mut signals = SummarySignals {
129 session: session.clone(),
130 source_kind: SummarySourceKind::SessionSignals,
131 source_label: "session_events".to_string(),
132 timeline_signals: timeline,
133 file_changes: files,
134 source_details: HashMap::new(),
135 };
136
137 if signals.is_empty() && settings.allows_git_changes_fallback() {
138 if let Some(request) = git_request {
139 if let Some(git_ctx) = collect_git_context(request) {
140 signals = summary_signals_from_git(git_ctx)?;
141 }
142 }
143 }
144
145 summarize_from_signals(signals, settings).await
146}
147
148pub async fn summarize_git_commit(
149 repo_root: &Path,
150 commit: &str,
151 settings: &SummarySettings,
152) -> Result<SemanticSummaryArtifact, String> {
153 let request = GitSummaryRequest::from_commit(repo_root.to_path_buf(), commit.to_string());
154 let context = collect_git_context(&request)
155 .ok_or_else(|| format!("unable to collect git summary context for commit `{commit}`"))?;
156 summarize_from_signals(summary_signals_from_git(context)?, settings).await
157}
158
159pub async fn summarize_git_working_tree(
160 repo_root: &Path,
161 settings: &SummarySettings,
162) -> Result<SemanticSummaryArtifact, String> {
163 let request = GitSummaryRequest::working_tree(repo_root.to_path_buf());
164 let context = collect_git_context(&request)
165 .ok_or_else(|| "unable to collect git summary context for working tree".to_string())?;
166 summarize_from_signals(summary_signals_from_git(context)?, settings).await
167}
168
169#[derive(Debug, Clone)]
170struct SummarySignals {
171 session: Session,
172 source_kind: SummarySourceKind,
173 source_label: String,
174 timeline_signals: Vec<String>,
175 file_changes: Vec<HailCompactFileChange>,
176 source_details: HashMap<String, String>,
177}
178
179impl SummarySignals {
180 fn is_empty(&self) -> bool {
181 self.timeline_signals.is_empty() && self.file_changes.is_empty()
182 }
183}
184
185async fn summarize_from_signals(
186 signals: SummarySignals,
187 settings: &SummarySettings,
188) -> Result<SemanticSummaryArtifact, String> {
189 let prompt_template = if settings.prompt.template.trim().is_empty() {
190 DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2
191 } else {
192 settings.prompt.template.as_str()
193 };
194 if let Err(error) = validate_summary_prompt_template(prompt_template) {
195 return Ok(SemanticSummaryArtifact {
196 summary: heuristic_summary(&signals.timeline_signals, &signals.file_changes),
197 source_kind: signals.source_kind,
198 generation_kind: SummaryGenerationKind::HeuristicFallback,
199 provider: settings.provider.id.clone(),
200 model: settings.provider.model.clone(),
201 prompt_fingerprint: String::new(),
202 diff_tree: build_diff_tree(&signals.file_changes, &signals.session.events),
203 source_details: signals.source_details,
204 error: Some(format!("invalid summary prompt template: {error}")),
205 });
206 }
207
208 let prompt = build_summary_prompt(
209 &signals.session,
210 signals.source_label.clone(),
211 signals.timeline_signals.clone(),
212 signals.file_changes.clone(),
213 serde_json::json!(signals.source_details),
214 SummaryPromptConfig {
215 response_style: settings.response.style.clone(),
216 output_shape: settings.response.shape.clone(),
217 source_mode: settings.source_mode.clone(),
218 prompt_template,
219 },
220 );
221
222 let prompt_fingerprint = sha256_hex(if prompt.is_empty() {
223 signals.source_label.as_bytes()
224 } else {
225 prompt.as_bytes()
226 });
227
228 let diff_tree = build_diff_tree(&signals.file_changes, &signals.session.events);
229
230 if signals.is_empty() {
231 return Ok(SemanticSummaryArtifact {
232 summary: heuristic_summary(&signals.timeline_signals, &signals.file_changes),
233 source_kind: SummarySourceKind::Heuristic,
234 generation_kind: SummaryGenerationKind::HeuristicFallback,
235 provider: settings.provider.id.clone(),
236 model: settings.provider.model.clone(),
237 prompt_fingerprint,
238 diff_tree,
239 source_details: signals.source_details,
240 error: Some("no usable summary signals found".to_string()),
241 });
242 }
243
244 if !settings.is_configured() || prompt.trim().is_empty() {
245 return Ok(SemanticSummaryArtifact {
246 summary: heuristic_summary(&signals.timeline_signals, &signals.file_changes),
247 source_kind: signals.source_kind,
248 generation_kind: SummaryGenerationKind::HeuristicFallback,
249 provider: settings.provider.id.clone(),
250 model: settings.provider.model.clone(),
251 prompt_fingerprint,
252 diff_tree,
253 source_details: signals.source_details,
254 error: None,
255 });
256 }
257
258 match generate_summary(settings, &prompt).await {
259 Ok(summary) => Ok(SemanticSummaryArtifact {
260 summary,
261 source_kind: signals.source_kind,
262 generation_kind: SummaryGenerationKind::Provider,
263 provider: settings.provider.id.clone(),
264 model: settings.provider.model.clone(),
265 prompt_fingerprint,
266 diff_tree,
267 source_details: signals.source_details,
268 error: None,
269 }),
270 Err(error) => Ok(SemanticSummaryArtifact {
271 summary: heuristic_summary(&signals.timeline_signals, &signals.file_changes),
272 source_kind: signals.source_kind,
273 generation_kind: SummaryGenerationKind::HeuristicFallback,
274 provider: settings.provider.id.clone(),
275 model: settings.provider.model.clone(),
276 prompt_fingerprint,
277 diff_tree,
278 source_details: signals.source_details,
279 error: Some(error),
280 }),
281 }
282}
283
284fn collect_git_context(request: &GitSummaryRequest) -> Option<GitSummaryContext> {
285 let service = GitSummaryService::new(ShellGitCommandRunner);
286 if let Some(commit) = request.commit.as_deref() {
287 return service.collect_commit_context(
288 &request.repo_root,
289 commit,
290 MAX_FILE_CHANGE_ENTRIES,
291 classify_arch_layer,
292 );
293 }
294
295 service.collect_working_tree_context(
296 &request.repo_root,
297 MAX_FILE_CHANGE_ENTRIES,
298 classify_arch_layer,
299 )
300}
301
302fn summary_signals_from_git(context: GitSummaryContext) -> Result<SummarySignals, String> {
303 let mut session = Session::new(
304 context
305 .commit
306 .clone()
307 .unwrap_or_else(|| "git-working-tree".to_string()),
308 Agent {
309 provider: "local".to_string(),
310 model: "git".to_string(),
311 tool: "git".to_string(),
312 tool_version: None,
313 },
314 );
315 session.context.title = Some(match context.commit.as_deref() {
316 Some(commit) => format!("Git commit {commit}"),
317 None => "Git working tree".to_string(),
318 });
319 session.stats.files_changed = context.file_changes.len() as u64;
320 session.stats.lines_added = context
321 .file_changes
322 .iter()
323 .map(|row| row.lines_added)
324 .sum::<u64>();
325 session.stats.lines_removed = context
326 .file_changes
327 .iter()
328 .map(|row| row.lines_removed)
329 .sum::<u64>();
330 session.stats.event_count = context.timeline_signals.len() as u64;
331 session.stats.message_count = context.timeline_signals.len() as u64;
332
333 let mut source_details = HashMap::from([(
334 "repo_root".to_string(),
335 context.repo_root.to_string_lossy().to_string(),
336 )]);
337 if let Some(commit) = context.commit.clone() {
338 source_details.insert("commit".to_string(), commit);
339 }
340
341 let (source_kind, source_label) = match context.source.as_str() {
342 "git_commit" => (SummarySourceKind::GitCommit, "git_commit".to_string()),
343 _ => (
344 SummarySourceKind::GitWorkingTree,
345 "git_working_tree".to_string(),
346 ),
347 };
348
349 if context.timeline_signals.is_empty() && context.file_changes.is_empty() {
350 return Err("git context has no timeline/file signals".to_string());
351 }
352
353 Ok(SummarySignals {
354 session,
355 source_kind,
356 source_label,
357 timeline_signals: context.timeline_signals,
358 file_changes: context.file_changes,
359 source_details,
360 })
361}
362
363fn default_event_snippet(event: &Event, max_chars: usize) -> Option<String> {
364 for block in &event.content.blocks {
365 let value = match block {
366 ContentBlock::Text { text } => text.as_str(),
367 ContentBlock::Code { code, .. } => code.as_str(),
368 ContentBlock::File { content, .. } => content.as_deref().unwrap_or_default(),
369 ContentBlock::Json { data } => {
370 let json = serde_json::to_string(data).ok()?;
371 return Some(compact_summary_snippet(&json, max_chars));
372 }
373 ContentBlock::Reference { uri, .. } => uri.as_str(),
374 ContentBlock::Image { url, .. }
375 | ContentBlock::Audio { url, .. }
376 | ContentBlock::Video { url, .. } => url.as_str(),
377 _ => continue,
378 };
379 let compact = compact_summary_snippet(value, max_chars);
380 if !compact.is_empty() {
381 return Some(compact);
382 }
383 }
384 None
385}
386
387fn heuristic_summary(timeline: &[String], files: &[HailCompactFileChange]) -> SemanticSummary {
388 let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
389 for change in files {
390 grouped
391 .entry(change.layer.clone())
392 .or_default()
393 .push(change.path.clone());
394 }
395
396 let total_added = files.iter().map(|row| row.lines_added).sum::<u64>();
397 let total_removed = files.iter().map(|row| row.lines_removed).sum::<u64>();
398 let base_changes = if files.is_empty() {
399 if timeline.is_empty() {
400 "No meaningful code-change signals were captured.".to_string()
401 } else {
402 format!(
403 "Session signals captured {} timeline entries; no concrete file changes were detected.",
404 timeline.len()
405 )
406 }
407 } else {
408 format!(
409 "Updated {} files across {} layers (+{} / -{} lines).",
410 files.len(),
411 grouped.len(),
412 total_added,
413 total_removed,
414 )
415 };
416
417 let auth_security = if files
418 .iter()
419 .any(|row| contains_auth_security_keyword(&row.path))
420 || timeline
421 .iter()
422 .any(|line| contains_auth_security_keyword(line))
423 {
424 "Auth/security-related changes detected in paths or timeline signals.".to_string()
425 } else {
426 "none detected".to_string()
427 };
428
429 let layer_file_changes = grouped
430 .into_iter()
431 .map(|(layer, mut paths)| {
432 paths.sort();
433 paths.dedup();
434 let summary = format!("{} files changed in {} layer.", paths.len(), layer);
435 provider::LayerFileChange {
436 layer,
437 summary,
438 files: paths,
439 }
440 })
441 .collect();
442
443 SemanticSummary {
444 changes: base_changes,
445 auth_security,
446 layer_file_changes,
447 }
448}
449
450fn sha256_hex(bytes: &[u8]) -> String {
451 let mut hasher = Sha256::new();
452 hasher.update(bytes);
453 let digest = hasher.finalize();
454 hex::encode(digest)
455}
456
457fn build_diff_tree(changes: &[HailCompactFileChange], events: &[Event]) -> Vec<DiffLayerNode> {
458 let mut diff_by_path: HashMap<&str, &str> = HashMap::new();
459 for event in events {
460 if let EventType::FileEdit {
461 path,
462 diff: Some(diff),
463 } = &event.event_type
464 {
465 diff_by_path.insert(path.as_str(), diff.as_str());
466 }
467 }
468
469 let mut grouped: BTreeMap<String, Vec<DiffFileNode>> = BTreeMap::new();
470
471 for change in changes {
472 let path = change.path.clone();
473 let operation = change.operation.clone();
474 let hunks = diff_by_path
475 .get(path.as_str())
476 .map(|diff| parse_diff_hunks(diff))
477 .unwrap_or_default();
478
479 let is_large = change.lines_added + change.lines_removed > 1_200
480 || hunks.iter().map(|h| h.lines.len()).sum::<usize>() > 200;
481
482 grouped
483 .entry(change.layer.clone())
484 .or_default()
485 .push(DiffFileNode {
486 path,
487 operation,
488 lines_added: change.lines_added,
489 lines_removed: change.lines_removed,
490 hunks,
491 is_large,
492 });
493 }
494
495 grouped
496 .into_iter()
497 .map(|(layer, mut files)| {
498 files.sort_by(|left, right| left.path.cmp(&right.path));
499 if files.len() > MAX_DIFF_FILES_PER_LAYER {
500 files.truncate(MAX_DIFF_FILES_PER_LAYER);
501 }
502 let lines_added = files.iter().map(|file| file.lines_added).sum::<u64>();
503 let lines_removed = files.iter().map(|file| file.lines_removed).sum::<u64>();
504 let file_count = files.len();
505
506 DiffLayerNode {
507 layer,
508 file_count,
509 lines_added,
510 lines_removed,
511 files,
512 }
513 })
514 .collect()
515}
516
517fn parse_diff_hunks(diff: &str) -> Vec<DiffHunkNode> {
518 let mut hunks = Vec::new();
519 let mut current_header = String::new();
520 let mut current_lines = Vec::new();
521 let mut current_added = 0u64;
522 let mut current_removed = 0u64;
523 let mut omitted = 0u64;
524
525 let push_current = |hunks: &mut Vec<DiffHunkNode>,
526 header: &mut String,
527 lines: &mut Vec<String>,
528 added: &mut u64,
529 removed: &mut u64,
530 omitted_lines: &mut u64| {
531 if header.is_empty() && lines.is_empty() {
532 return;
533 }
534 hunks.push(DiffHunkNode {
535 header: if header.is_empty() {
536 "(diff)".to_string()
537 } else {
538 header.clone()
539 },
540 lines: std::mem::take(lines),
541 lines_added: *added,
542 lines_removed: *removed,
543 omitted_lines: *omitted_lines,
544 });
545 header.clear();
546 *added = 0;
547 *removed = 0;
548 *omitted_lines = 0;
549 };
550
551 for raw in diff.lines() {
552 if raw.starts_with("@@") {
553 push_current(
554 &mut hunks,
555 &mut current_header,
556 &mut current_lines,
557 &mut current_added,
558 &mut current_removed,
559 &mut omitted,
560 );
561 current_header = compact_summary_snippet(raw, 140);
562 continue;
563 }
564 if current_header.is_empty() {
565 continue;
566 }
567
568 if raw.starts_with('+') && !raw.starts_with("+++") {
569 current_added = current_added.saturating_add(1);
570 } else if raw.starts_with('-') && !raw.starts_with("---") {
571 current_removed = current_removed.saturating_add(1);
572 }
573
574 if current_lines.len() < MAX_DIFF_LINES_PER_HUNK {
575 current_lines.push(compact_summary_snippet(raw, 220));
576 } else {
577 omitted = omitted.saturating_add(1);
578 }
579 }
580
581 push_current(
582 &mut hunks,
583 &mut current_header,
584 &mut current_lines,
585 &mut current_added,
586 &mut current_removed,
587 &mut omitted,
588 );
589
590 if hunks.len() > MAX_DIFF_HUNKS_PER_FILE {
591 hunks.truncate(MAX_DIFF_HUNKS_PER_FILE);
592 }
593 hunks
594}
595
596#[cfg(test)]
597mod tests {
598 use super::{
599 build_diff_tree, default_event_snippet, heuristic_summary, parse_diff_hunks,
600 summarize_session, DiffLayerNode, GitSummaryRequest, SummaryGenerationKind,
601 SummarySourceKind,
602 };
603 use crate::types::HailCompactFileChange;
604 use chrono::Utc;
605 use opensession_core::trace::{Agent, Content, Event, EventType, Session};
606 use opensession_runtime_config::{SummaryProvider, SummarySettings};
607 use std::collections::HashMap;
608
609 fn session_with_file_edit(path: &str, diff: &str) -> Session {
610 let mut session = Session::new(
611 "s1".to_string(),
612 Agent {
613 provider: "openai".to_string(),
614 model: "gpt-5".to_string(),
615 tool: "codex".to_string(),
616 tool_version: None,
617 },
618 );
619
620 session.events.push(Event {
621 event_id: "u1".to_string(),
622 timestamp: Utc::now(),
623 event_type: EventType::UserMessage,
624 task_id: None,
625 content: Content::text("fix auth token flow"),
626 duration_ms: None,
627 attributes: HashMap::new(),
628 });
629
630 session.events.push(Event {
631 event_id: "f1".to_string(),
632 timestamp: Utc::now(),
633 event_type: EventType::FileEdit {
634 path: path.to_string(),
635 diff: Some(diff.to_string()),
636 },
637 task_id: None,
638 content: Content::text(""),
639 duration_ms: None,
640 attributes: HashMap::new(),
641 });
642 session.recompute_stats();
643 session
644 }
645
646 #[test]
647 fn parse_diff_hunks_extracts_header_and_line_stats() {
648 let hunks =
649 parse_diff_hunks("@@ -1,2 +1,2 @@\n-old\n+new\n context\n@@ -5 +5 @@\n-a\n+b\n");
650 assert_eq!(hunks.len(), 2);
651 assert_eq!(hunks[0].lines_added, 1);
652 assert_eq!(hunks[0].lines_removed, 1);
653 assert!(hunks[0].header.starts_with("@@ -1,2"));
654 }
655
656 #[test]
657 fn default_event_snippet_prefers_text_blocks() {
658 let event = Event {
659 event_id: "e1".to_string(),
660 timestamp: Utc::now(),
661 event_type: EventType::UserMessage,
662 task_id: None,
663 content: Content::text(" hello world "),
664 duration_ms: None,
665 attributes: HashMap::new(),
666 };
667 assert_eq!(
668 default_event_snippet(&event, 40),
669 Some("hello world".to_string())
670 );
671 }
672
673 #[test]
674 fn heuristic_summary_marks_auth_changes() {
675 let summary = heuristic_summary(
676 &["assistant: updated auth middleware".to_string()],
677 &[HailCompactFileChange {
678 path: "src/auth.rs".to_string(),
679 layer: "application".to_string(),
680 operation: "edit".to_string(),
681 lines_added: 2,
682 lines_removed: 1,
683 }],
684 );
685 assert!(summary.auth_security.contains("Auth/security"));
686 assert_eq!(summary.layer_file_changes.len(), 1);
687 }
688
689 #[test]
690 fn build_diff_tree_groups_by_layer() {
691 let session = session_with_file_edit("src/lib.rs", "@@ -1 +1 @@\n-a\n+b\n");
692 let tree = build_diff_tree(
693 &[HailCompactFileChange {
694 path: "src/lib.rs".to_string(),
695 layer: "application".to_string(),
696 operation: "edit".to_string(),
697 lines_added: 1,
698 lines_removed: 1,
699 }],
700 &session.events,
701 );
702
703 assert_eq!(tree.len(), 1);
704 let layer: &DiffLayerNode = &tree[0];
705 assert_eq!(layer.layer, "application");
706 assert_eq!(layer.files.len(), 1);
707 assert_eq!(layer.files[0].hunks.len(), 1);
708 }
709
710 #[tokio::test]
711 async fn summarize_session_falls_back_to_heuristic_when_provider_disabled() {
712 let session = session_with_file_edit("src/auth.rs", "@@ -1 +1 @@\n-a\n+b\n");
713 let settings = SummarySettings::default();
714
715 let artifact = summarize_session(&session, &settings, None)
716 .await
717 .expect("summarize");
718 assert_eq!(
719 artifact.generation_kind,
720 SummaryGenerationKind::HeuristicFallback
721 );
722 assert_eq!(artifact.source_kind, SummarySourceKind::SessionSignals);
723 assert_eq!(artifact.provider, SummaryProvider::Disabled);
724 assert!(!artifact.summary.changes.is_empty());
725 assert_eq!(artifact.error, None);
726 }
727
728 #[tokio::test]
729 async fn summarize_session_uses_git_fallback_when_session_has_low_signal() {
730 let mut session = Session::new(
731 "s-empty".to_string(),
732 Agent {
733 provider: "openai".to_string(),
734 model: "gpt-5".to_string(),
735 tool: "codex".to_string(),
736 tool_version: None,
737 },
738 );
739 session.recompute_stats();
740
741 let mut settings = SummarySettings::default();
742 settings.source_mode = opensession_runtime_config::SummarySourceMode::SessionOrGitChanges;
743
744 let artifact = summarize_session(
745 &session,
746 &settings,
747 Some(&GitSummaryRequest::working_tree(std::env::temp_dir())),
748 )
749 .await
750 .expect("summarize");
751
752 assert!(matches!(
754 artifact.source_kind,
755 SummarySourceKind::SessionSignals | SummarySourceKind::Heuristic
756 ));
757 }
758}