Skip to main content

opensession_summary/
lib.rs

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        // temp dir is typically not a git repo, so fallback remains heuristic/session.
753        assert!(matches!(
754            artifact.source_kind,
755            SummarySourceKind::SessionSignals | SummarySourceKind::Heuristic
756        ));
757    }
758}