Skip to main content

opensession_summary/
prompt.rs

1use crate::text::compact_summary_snippet;
2use crate::types::HailCompactFileChange;
3use opensession_core::trace::{Event, EventType, Session};
4use opensession_runtime_config::{SummaryOutputShape, SummaryResponseStyle, SummarySourceMode};
5use serde::Serialize;
6use serde_json::Value;
7use std::collections::{BTreeMap, HashMap};
8
9const MAX_PROMPT_CHARS: usize = 16_000;
10const MAX_EVIDENCE_FILES: usize = 24;
11const MAX_EVIDENCE_SAMPLES_PER_KIND: usize = 3;
12const MAX_EVIDENCE_LINE_CHARS: usize = 120;
13const MAX_COVERAGE_TARGETS: usize = 6;
14pub const DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2: &str = "Convert a real coding session into semantic compression.\n\
15Pipeline: session -> HAIL compact -> semantic summary.\n\
16Return JSON only (no markdown, no prose outside JSON):\n\
17{\n\
18  \"changes\": \"overall code change summary\",\n\
19  \"auth_security\": \"auth/security change summary or 'none detected'\",\n\
20  \"layer_file_changes\": [\n\
21    {\"layer\":\"presentation|application|domain|infrastructure|tests|docs|config\", \"summary\":\"layer change summary\", \"files\":[\"path\"]}\n\
22  ]\n\
23}\n\
24Rules:\n\
25- Use only facts from HAIL_COMPACT.\n\
26- Derive semantic meaning from timeline_signals + change_evidence (intent, implementation, impact).\n\
27- If intent is unclear from signals, explicitly say intent is unclear instead of guessing.\n\
28- In \"changes\", include: (1) goal/intent, (2) concrete modifications, (3) expected impact.\n\
29- Mention concrete modified files and operations (create/edit/delete), prioritizing high-change files.\n\
30- If no auth/security-related change exists, set auth_security to \"none detected\".\n\
31- In layer_file_changes, group by architectural layer and make each summary describe what changed + why it matters.\n\
32- Prefer concrete identifiers from signals (file path, API/config key, command, component/module name).\n\
33- Keep output compact, factual, and free of generic filler.\n\
34- Use the same language as the session signals when obvious.\n\
35{{COVERAGE_RULE}}\n\
36{{SOURCE_RULE}}\n\
37{{STYLE_RULE}}\n\
38{{SHAPE_RULE}}\n\
39HAIL_COMPACT={{HAIL_COMPACT}}";
40
41#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
42pub struct HailCompactLayerRollup {
43    layer: String,
44    file_count: usize,
45    files: Vec<String>,
46}
47
48#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct HailCompactChangeEvidence {
50    path: String,
51    layer: String,
52    operation: String,
53    lines_added: u64,
54    lines_removed: u64,
55    #[serde(default)]
56    added_samples: Vec<String>,
57    #[serde(default)]
58    removed_samples: Vec<String>,
59}
60
61pub struct SummaryPromptConfig<'a> {
62    pub response_style: SummaryResponseStyle,
63    pub output_shape: SummaryOutputShape,
64    pub source_mode: SummarySourceMode,
65    pub prompt_template: &'a str,
66}
67
68pub fn validate_summary_prompt_template(template: &str) -> Result<(), String> {
69    let trimmed = template.trim();
70    if trimmed.is_empty() {
71        return Err("template must not be empty".to_string());
72    }
73    if !trimmed.contains("{{HAIL_COMPACT}}") {
74        return Err("template must include {{HAIL_COMPACT}} placeholder".to_string());
75    }
76    Ok(())
77}
78
79pub fn collect_timeline_snippets(
80    session: &Session,
81    max_entries: usize,
82    event_snippet: fn(&Event, usize) -> Option<String>,
83) -> Vec<String> {
84    let mut snippets = Vec::new();
85    for event in session.events.iter().rev() {
86        if snippets.len() >= max_entries {
87            break;
88        }
89
90        let label = match &event.event_type {
91            EventType::UserMessage => "user",
92            EventType::AgentMessage => "assistant",
93            EventType::Thinking => "thinking",
94            EventType::TaskStart { .. } => "task_start",
95            EventType::TaskEnd { .. } => "task_end",
96            EventType::ToolCall { .. } | EventType::ToolResult { .. } => "tool",
97            _ => continue,
98        };
99
100        let snippet = match &event.event_type {
101            EventType::TaskEnd {
102                summary: Some(summary),
103            } => Some(compact_summary_snippet(summary, 220)),
104            _ => event_snippet(event, 220),
105        };
106        let Some(text) = snippet else {
107            continue;
108        };
109        if text.is_empty() {
110            continue;
111        }
112        snippets.push(format!("{label}: {text}"));
113    }
114    snippets.reverse();
115    snippets
116}
117
118pub fn count_diff_stats(diff: &str) -> (u64, u64) {
119    let mut added = 0u64;
120    let mut removed = 0u64;
121
122    for line in diff.lines() {
123        if line.starts_with("+++") || line.starts_with("---") {
124            continue;
125        }
126        if line.starts_with('+') {
127            added = added.saturating_add(1);
128        } else if line.starts_with('-') {
129            removed = removed.saturating_add(1);
130        }
131    }
132
133    (added, removed)
134}
135
136pub fn classify_arch_layer(path: &str) -> &'static str {
137    let normalized = path.replace('\\', "/").to_ascii_lowercase();
138    let file_name = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
139
140    if normalized.starts_with("docs/")
141        || normalized.contains("/docs/")
142        || file_name.ends_with(".md")
143        || file_name.ends_with(".mdx")
144    {
145        return "docs";
146    }
147
148    if normalized.starts_with("tests/")
149        || normalized.contains("/tests/")
150        || normalized.contains("/test/")
151        || file_name.ends_with("_test.rs")
152        || file_name.ends_with(".spec.ts")
153        || file_name.ends_with(".test.ts")
154        || file_name.ends_with(".spec.tsx")
155        || file_name.ends_with(".test.tsx")
156        || file_name.ends_with(".spec.js")
157        || file_name.ends_with(".test.js")
158    {
159        return "tests";
160    }
161
162    if normalized.ends_with("cargo.toml")
163        || normalized.ends_with("cargo.lock")
164        || normalized.ends_with("package.json")
165        || normalized.ends_with("package-lock.json")
166        || normalized.ends_with("pnpm-lock.yaml")
167        || normalized.ends_with("yarn.lock")
168        || normalized.ends_with("wrangler.toml")
169        || normalized.ends_with(".toml")
170        || normalized.ends_with(".yaml")
171        || normalized.ends_with(".yml")
172        || normalized.ends_with(".json")
173        || normalized.ends_with(".ini")
174        || normalized.ends_with(".conf")
175        || normalized.starts_with("config/")
176        || normalized.contains("/config/")
177        || normalized.contains("runtime-config")
178        || normalized.starts_with(".github/")
179        || normalized.contains("/.github/")
180    {
181        return "config";
182    }
183
184    if normalized.contains("/ui/")
185        || normalized.contains("/views/")
186        || normalized.contains("/components/")
187        || normalized.contains("/pages/")
188        || normalized.contains("/widgets/")
189        || normalized.contains("/frontend/")
190        || normalized.contains("/presentation/")
191        || normalized.contains("packages/ui/src/")
192        || normalized.contains("web/src/routes/")
193        || file_name == "ui.rs"
194    {
195        return "presentation";
196    }
197
198    if normalized.contains("/domain/")
199        || normalized.contains("/entity/")
200        || normalized.contains("/entities/")
201        || normalized.contains("/model/")
202        || normalized.contains("/models/")
203        || normalized.contains("/value_object/")
204        || normalized.contains("/aggregate/")
205        || normalized.contains("crates/core/")
206    {
207        return "domain";
208    }
209
210    if normalized.contains("/infra/")
211        || normalized.contains("/infrastructure/")
212        || normalized.contains("/adapter/")
213        || normalized.contains("/adapters/")
214        || normalized.contains("/storage/")
215        || normalized.contains("/repository/")
216        || normalized.contains("/repositories/")
217        || normalized.contains("/db/")
218        || normalized.contains("/database/")
219        || normalized.contains("/runtime/")
220        || normalized.contains("/daemon/")
221        || normalized.contains("/network/")
222        || normalized.contains("/api/")
223        || normalized.contains("/git/")
224        || normalized.contains("/migrations/")
225        || normalized.starts_with("scripts/")
226    {
227        return "infrastructure";
228    }
229
230    "application"
231}
232
233pub fn contains_auth_security_keyword(text: &str) -> bool {
234    let normalized = text.to_ascii_lowercase();
235    [
236        "auth",
237        "oauth",
238        "oidc",
239        "saml",
240        "token",
241        "jwt",
242        "bearer",
243        "apikey",
244        "api_key",
245        "api-key",
246        "secret",
247        "password",
248        "credential",
249        "login",
250        "logout",
251        "sign-in",
252        "signin",
253        "mfa",
254        "2fa",
255        "permission",
256        "rbac",
257        "acl",
258        "encrypt",
259        "decrypt",
260        "security",
261        "csrf",
262        "xss",
263        "csp",
264        "cookie",
265        "set-cookie",
266        "hmac",
267        "signature",
268        "nonce",
269        "tls",
270        "ssl",
271    ]
272    .iter()
273    .any(|keyword| normalized.contains(keyword))
274}
275
276pub fn collect_file_changes(session: &Session, max_entries: usize) -> Vec<HailCompactFileChange> {
277    let mut by_path: HashMap<String, HailCompactFileChange> = HashMap::new();
278    for event in &session.events {
279        match &event.event_type {
280            EventType::FileEdit { path, diff } => {
281                let (added, removed) = count_diff_stats(diff.as_deref().unwrap_or_default());
282                let entry = by_path
283                    .entry(path.clone())
284                    .or_insert_with(|| HailCompactFileChange {
285                        path: path.clone(),
286                        layer: classify_arch_layer(path).to_string(),
287                        operation: "edit".to_string(),
288                        lines_added: 0,
289                        lines_removed: 0,
290                    });
291                entry.operation = "edit".to_string();
292                entry.lines_added = entry.lines_added.saturating_add(added);
293                entry.lines_removed = entry.lines_removed.saturating_add(removed);
294            }
295            EventType::FileCreate { path } => {
296                by_path
297                    .entry(path.clone())
298                    .and_modify(|entry| {
299                        entry.operation = "create".to_string();
300                        entry.layer = classify_arch_layer(path).to_string();
301                    })
302                    .or_insert_with(|| HailCompactFileChange {
303                        path: path.clone(),
304                        layer: classify_arch_layer(path).to_string(),
305                        operation: "create".to_string(),
306                        lines_added: 0,
307                        lines_removed: 0,
308                    });
309            }
310            EventType::FileDelete { path } => {
311                by_path
312                    .entry(path.clone())
313                    .and_modify(|entry| {
314                        entry.operation = "delete".to_string();
315                        entry.layer = classify_arch_layer(path).to_string();
316                    })
317                    .or_insert_with(|| HailCompactFileChange {
318                        path: path.clone(),
319                        layer: classify_arch_layer(path).to_string(),
320                        operation: "delete".to_string(),
321                        lines_added: 0,
322                        lines_removed: 0,
323                    });
324            }
325            _ => {}
326        }
327    }
328
329    let mut changes = by_path.into_values().collect::<Vec<_>>();
330    changes.sort_by(|lhs, rhs| lhs.path.cmp(&rhs.path));
331    changes.truncate(max_entries);
332    changes
333}
334
335fn collect_change_evidence(
336    session: &Session,
337    file_changes: &[HailCompactFileChange],
338    max_entries: usize,
339) -> Vec<HailCompactChangeEvidence> {
340    let mut evidence_by_path = file_changes
341        .iter()
342        .map(|change| {
343            (
344                change.path.clone(),
345                HailCompactChangeEvidence {
346                    path: change.path.clone(),
347                    layer: change.layer.clone(),
348                    operation: change.operation.clone(),
349                    lines_added: change.lines_added,
350                    lines_removed: change.lines_removed,
351                    added_samples: Vec::new(),
352                    removed_samples: Vec::new(),
353                },
354            )
355        })
356        .collect::<HashMap<_, _>>();
357
358    for event in &session.events {
359        let EventType::FileEdit {
360            path,
361            diff: Some(diff),
362        } = &event.event_type
363        else {
364            continue;
365        };
366        let Some(entry) = evidence_by_path.get_mut(path) else {
367            continue;
368        };
369        append_diff_samples(
370            diff,
371            &mut entry.added_samples,
372            &mut entry.removed_samples,
373            MAX_EVIDENCE_SAMPLES_PER_KIND,
374        );
375    }
376
377    let mut evidence = evidence_by_path.into_values().collect::<Vec<_>>();
378    evidence.sort_by(|lhs, rhs| {
379        rhs.lines_added
380            .saturating_add(rhs.lines_removed)
381            .cmp(&lhs.lines_added.saturating_add(lhs.lines_removed))
382            .then_with(|| lhs.path.cmp(&rhs.path))
383    });
384    evidence.truncate(max_entries);
385    evidence
386}
387
388fn append_diff_samples(
389    diff: &str,
390    added_samples: &mut Vec<String>,
391    removed_samples: &mut Vec<String>,
392    max_samples: usize,
393) {
394    for line in diff.lines() {
395        if line.starts_with("diff --git")
396            || line.starts_with("+++")
397            || line.starts_with("---")
398            || line.starts_with("@@")
399        {
400            continue;
401        }
402
403        if let Some(raw) = line.strip_prefix('+') {
404            push_sample(added_samples, raw, max_samples);
405        } else if let Some(raw) = line.strip_prefix('-') {
406            push_sample(removed_samples, raw, max_samples);
407        }
408
409        if added_samples.len() >= max_samples && removed_samples.len() >= max_samples {
410            break;
411        }
412    }
413}
414
415fn push_sample(samples: &mut Vec<String>, raw_line: &str, max_samples: usize) {
416    if samples.len() >= max_samples {
417        return;
418    }
419    let normalized = compact_summary_snippet(raw_line, MAX_EVIDENCE_LINE_CHARS);
420    if normalized.is_empty() {
421        return;
422    }
423    if normalized.eq_ignore_ascii_case("binary files differ")
424        || normalized.eq_ignore_ascii_case("no newline at end of file")
425    {
426        return;
427    }
428    if samples.iter().any(|item| item == &normalized) {
429        return;
430    }
431    samples.push(normalized);
432}
433
434fn build_coverage_rule(file_changes: &[HailCompactFileChange]) -> String {
435    if file_changes.is_empty() {
436        return "- Coverage requirement: no concrete file_changes were provided; state limitations from timeline signals only.".to_string();
437    }
438
439    let mut prioritized = file_changes.to_vec();
440    prioritized.sort_by(|lhs, rhs| {
441        rhs.lines_added
442            .saturating_add(rhs.lines_removed)
443            .cmp(&lhs.lines_added.saturating_add(lhs.lines_removed))
444            .then_with(|| lhs.path.cmp(&rhs.path))
445    });
446    prioritized.truncate(MAX_COVERAGE_TARGETS);
447
448    let required_mentions = prioritized.len().min(3);
449    let targets = prioritized
450        .iter()
451        .map(|row| {
452            format!(
453                "{} ({}, +{}/-{})",
454                row.path, row.operation, row.lines_added, row.lines_removed
455            )
456        })
457        .collect::<Vec<_>>();
458
459    format!(
460        "- Coverage requirement: mention at least {required_mentions} concrete file paths from MUST_COVER_FILES across changes or layer_file_changes when file_changes exists.\nMUST_COVER_FILES=[{}]",
461        targets.join("; ")
462    )
463}
464
465pub fn build_summary_prompt(
466    session: &Session,
467    source_kind: String,
468    timeline_snippets: Vec<String>,
469    file_changes: Vec<HailCompactFileChange>,
470    git_context: Value,
471    config: SummaryPromptConfig<'_>,
472) -> String {
473    if timeline_snippets.is_empty() && file_changes.is_empty() {
474        return String::new();
475    }
476
477    let layer_rollup = summarize_layer_rollup(&file_changes);
478    let change_evidence = collect_change_evidence(session, &file_changes, MAX_EVIDENCE_FILES);
479    let auth_security_signals = collect_auth_security_signals(&file_changes, &timeline_snippets);
480
481    let title = session
482        .context
483        .title
484        .as_deref()
485        .filter(|title| !title.trim().is_empty())
486        .unwrap_or(session.session_id.as_str());
487
488    let hail_compact = serde_json::json!({
489        "session": {
490            "id": session.session_id,
491            "title": title,
492            "tool": session.agent.tool,
493            "provider": session.agent.provider,
494            "model": session.agent.model,
495            "event_count": session.stats.event_count,
496            "message_count": session.stats.message_count,
497            "task_count": session.stats.task_count,
498            "files_changed": session.stats.files_changed,
499            "lines_added": session.stats.lines_added,
500            "lines_removed": session.stats.lines_removed
501        },
502        "summary_source": source_kind,
503        "timeline_signals": timeline_snippets,
504        "file_changes": file_changes,
505        "change_evidence": change_evidence,
506        "layer_rollup": layer_rollup,
507        "auth_security_signals": auth_security_signals,
508        "git_context": git_context
509    });
510    let compact_json = serde_json::to_string(&hail_compact).unwrap_or_default();
511    if compact_json.trim().is_empty() {
512        return String::new();
513    }
514
515    let style_rule = match config.response_style {
516        SummaryResponseStyle::Compact => {
517            "- Response style: compact. Keep each summary field concise (single short sentence when possible)."
518        }
519        SummaryResponseStyle::Standard => {
520            "- Response style: standard. Keep each field short but informative (1-2 sentences)."
521        }
522        SummaryResponseStyle::Detailed => {
523            "- Response style: detailed. Include concrete context and impact while staying factual."
524        }
525    };
526    let shape_rule = match config.output_shape {
527        SummaryOutputShape::Layered => {
528            "- Output shape: layered. Group file changes by architecture layer in layer_file_changes."
529        }
530        SummaryOutputShape::FileList => {
531            "- Output shape: file_list. Prefer file-centric entries (fine-grained grouping) in layer_file_changes."
532        }
533        SummaryOutputShape::SecurityFirst => {
534            "- Output shape: security_first. Prioritize auth/security-related changes first when present."
535        }
536    };
537    let source_rule = match config.source_mode {
538        SummarySourceMode::SessionOnly => {
539            "- Input source mode: session_only. Summarize only from session event signals."
540        }
541        SummarySourceMode::SessionOrGitChanges => {
542            "- Input source mode: session_or_git_changes. If session signals are empty, use git change signals from HAIL_COMPACT."
543        }
544    };
545    let coverage_rule = build_coverage_rule(&file_changes);
546    let prompt_template = if config.prompt_template.trim().is_empty() {
547        DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2
548    } else {
549        config.prompt_template
550    };
551    if validate_summary_prompt_template(prompt_template).is_err() {
552        return String::new();
553    }
554
555    // Keep custom templates flexible while still enforcing runtime response semantics.
556    // If rule placeholders are omitted, prepend them so shape/style/source always affect the prompt.
557    let mut normalized_template = prompt_template.to_string();
558    if !normalized_template.contains("{{SOURCE_RULE}}") {
559        normalized_template = format!("{{{{SOURCE_RULE}}}}\n{normalized_template}");
560    }
561    if !normalized_template.contains("{{STYLE_RULE}}") {
562        normalized_template = format!("{{{{STYLE_RULE}}}}\n{normalized_template}");
563    }
564    if !normalized_template.contains("{{SHAPE_RULE}}") {
565        normalized_template = format!("{{{{SHAPE_RULE}}}}\n{normalized_template}");
566    }
567    if !normalized_template.contains("{{COVERAGE_RULE}}") {
568        normalized_template = format!("{{{{COVERAGE_RULE}}}}\n{normalized_template}");
569    }
570
571    let mut prompt = normalized_template
572        .replace("{{SOURCE_RULE}}", source_rule)
573        .replace("{{STYLE_RULE}}", style_rule)
574        .replace("{{SHAPE_RULE}}", shape_rule)
575        .replace("{{COVERAGE_RULE}}", &coverage_rule)
576        .replace("{{HAIL_COMPACT}}", &compact_json);
577
578    if prompt.chars().count() > MAX_PROMPT_CHARS {
579        prompt = prompt.chars().take(MAX_PROMPT_CHARS).collect();
580    }
581    prompt
582}
583
584fn summarize_layer_rollup(changes: &[HailCompactFileChange]) -> Vec<HailCompactLayerRollup> {
585    let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
586    for change in changes {
587        grouped
588            .entry(change.layer.clone())
589            .or_default()
590            .push(change.path.clone());
591    }
592    grouped
593        .into_iter()
594        .map(|(layer, mut files)| {
595            files.sort();
596            files.dedup();
597            HailCompactLayerRollup {
598                layer,
599                file_count: files.len(),
600                files,
601            }
602        })
603        .collect()
604}
605
606fn collect_auth_security_signals(
607    changes: &[HailCompactFileChange],
608    timeline_snippets: &[String],
609) -> Vec<String> {
610    let mut signals = Vec::new();
611
612    for change in changes {
613        if contains_auth_security_keyword(&change.path) {
614            signals.push(format!("file:{}", change.path));
615        }
616    }
617
618    for snippet in timeline_snippets {
619        if contains_auth_security_keyword(snippet) {
620            signals.push(format!(
621                "timeline:{}",
622                compact_summary_snippet(snippet, 120)
623            ));
624        }
625        if signals.len() >= 12 {
626            break;
627        }
628    }
629
630    signals.sort();
631    signals.dedup();
632    signals
633}
634
635#[cfg(test)]
636mod tests {
637    use super::{
638        build_summary_prompt, classify_arch_layer, collect_file_changes, collect_timeline_snippets,
639        contains_auth_security_keyword, count_diff_stats, validate_summary_prompt_template,
640        SummaryPromptConfig, DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2,
641    };
642    use crate::types::HailCompactFileChange;
643    use chrono::Utc;
644    use opensession_core::trace::{Agent, Content, Event, EventType, Session};
645    use opensession_runtime_config::{SummaryOutputShape, SummaryResponseStyle, SummarySourceMode};
646    use serde_json::json;
647    use std::collections::HashMap;
648
649    fn make_session(session_id: &str) -> Session {
650        Session::new(
651            session_id.to_string(),
652            Agent {
653                provider: "openai".to_string(),
654                model: "gpt-5".to_string(),
655                tool: "codex".to_string(),
656                tool_version: None,
657            },
658        )
659    }
660
661    fn make_event(event_id: &str, event_type: EventType, text: &str) -> Event {
662        Event {
663            event_id: event_id.to_string(),
664            timestamp: Utc::now(),
665            event_type,
666            task_id: None,
667            content: Content::text(text),
668            duration_ms: None,
669            attributes: HashMap::new(),
670        }
671    }
672
673    fn event_snippet(event: &Event, _max_chars: usize) -> Option<String> {
674        if event.event_id.contains("skip") {
675            None
676        } else {
677            Some(format!("snippet-{}", event.event_id))
678        }
679    }
680
681    #[test]
682    fn count_diff_stats_counts_added_and_removed_lines() {
683        let diff = "\
684diff --git a/src/a.rs b/src/a.rs\n\
685--- a/src/a.rs\n\
686+++ b/src/a.rs\n\
687@@ -1,2 +1,3 @@\n\
688 line\n\
689-old\n\
690+new\n\
691+extra\n";
692
693        let (added, removed) = count_diff_stats(diff);
694        assert_eq!((added, removed), (2, 1));
695    }
696
697    #[test]
698    fn classify_arch_layer_prefers_expected_buckets() {
699        assert_eq!(
700            classify_arch_layer("packages/ui/src/components/SessionDetailPage.svelte"),
701            "presentation"
702        );
703        assert_eq!(
704            classify_arch_layer("crates/runtime-config/src/lib.rs"),
705            "config"
706        );
707        assert_eq!(
708            classify_arch_layer("tests/session_summary_test.rs"),
709            "tests"
710        );
711        assert_eq!(classify_arch_layer("docs/summary.md"), "docs");
712    }
713
714    #[test]
715    fn contains_auth_security_keyword_detects_common_security_terms() {
716        assert!(contains_auth_security_keyword(
717            "updated oauth token validation"
718        ));
719        assert!(contains_auth_security_keyword(
720            "set-cookie hardened for csrf"
721        ));
722        assert!(!contains_auth_security_keyword(
723            "refactored timeline renderer"
724        ));
725    }
726
727    #[test]
728    fn collect_timeline_snippets_prefers_task_end_summary_and_preserves_order() {
729        let mut session = make_session("timeline-summary");
730        session
731            .events
732            .push(make_event("e-user", EventType::UserMessage, "hello"));
733        session.events.push(make_event(
734            "skip-custom",
735            EventType::Custom {
736                kind: "x".to_string(),
737            },
738            "ignored",
739        ));
740        session.events.push(make_event(
741            "e-task-end",
742            EventType::TaskEnd {
743                summary: Some("  done   with   auth   ".to_string()),
744            },
745            "",
746        ));
747        session.events.push(make_event(
748            "e-tool",
749            EventType::ToolCall {
750                name: "apply_patch".to_string(),
751            },
752            "",
753        ));
754
755        let snippets = collect_timeline_snippets(&session, 10, event_snippet);
756        assert_eq!(snippets.len(), 3);
757        assert_eq!(snippets[0], "user: snippet-e-user");
758        assert_eq!(snippets[1], "task_end: done with auth");
759        assert_eq!(snippets[2], "tool: snippet-e-tool");
760    }
761
762    #[test]
763    fn collect_file_changes_merges_and_truncates_sorted_paths() {
764        let mut session = make_session("file-change-merge");
765        session.events.push(make_event(
766            "e1",
767            EventType::FileEdit {
768                path: "b.rs".to_string(),
769                diff: Some("+a\n-b\n+x\n".to_string()),
770            },
771            "",
772        ));
773        session.events.push(make_event(
774            "e2",
775            EventType::FileCreate {
776                path: "a.rs".to_string(),
777            },
778            "",
779        ));
780        session.events.push(make_event(
781            "e3",
782            EventType::FileEdit {
783                path: "b.rs".to_string(),
784                diff: Some("+k\n".to_string()),
785            },
786            "",
787        ));
788        session.events.push(make_event(
789            "e4",
790            EventType::FileDelete {
791                path: "c.rs".to_string(),
792            },
793            "",
794        ));
795
796        let changes = collect_file_changes(&session, 2);
797        assert_eq!(changes.len(), 2);
798        assert_eq!(changes[0].path, "a.rs");
799        assert_eq!(changes[0].operation, "create");
800        assert_eq!(changes[1].path, "b.rs");
801        assert_eq!(changes[1].operation, "edit");
802        assert_eq!(changes[1].lines_added, 3);
803        assert_eq!(changes[1].lines_removed, 1);
804    }
805
806    #[test]
807    fn build_summary_prompt_returns_empty_without_signals() {
808        let session = make_session("prompt-empty");
809        let prompt = build_summary_prompt(
810            &session,
811            "session_events".to_string(),
812            Vec::new(),
813            Vec::new(),
814            serde_json::Value::Null,
815            SummaryPromptConfig {
816                response_style: SummaryResponseStyle::Standard,
817                output_shape: SummaryOutputShape::Layered,
818                source_mode: SummarySourceMode::SessionOnly,
819                prompt_template: DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2,
820            },
821        );
822
823        assert!(prompt.is_empty());
824    }
825
826    #[test]
827    fn build_summary_prompt_reflects_style_shape_source_and_security_signals() {
828        let mut session = make_session("prompt-rules");
829        session
830            .events
831            .push(make_event("e-user", EventType::UserMessage, "summarize"));
832        session.recompute_stats();
833
834        let prompt = build_summary_prompt(
835            &session,
836            "git_working_tree".to_string(),
837            vec![
838                "assistant: fixed oauth token validation".to_string(),
839                "tool: refactor done".to_string(),
840            ],
841            vec![HailCompactFileChange {
842                path: "auth/login.rs".to_string(),
843                layer: "application".to_string(),
844                operation: "edit".to_string(),
845                lines_added: 8,
846                lines_removed: 2,
847            }],
848            json!({"repo_root":"/tmp/repo","commit":null}),
849            SummaryPromptConfig {
850                response_style: SummaryResponseStyle::Detailed,
851                output_shape: SummaryOutputShape::SecurityFirst,
852                source_mode: SummarySourceMode::SessionOrGitChanges,
853                prompt_template: DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2,
854            },
855        );
856
857        assert!(prompt.contains("- Response style: detailed."));
858        assert!(prompt.contains("- Output shape: security_first."));
859        assert!(prompt.contains("- Input source mode: session_or_git_changes."));
860        assert!(prompt.contains("\"summary_source\":\"git_working_tree\""));
861        assert!(prompt.contains("file:auth/login.rs"));
862        assert!(prompt.contains("timeline:assistant: fixed oauth token validation"));
863        assert!(prompt.contains("Coverage requirement: mention at least 1 concrete file paths"));
864        assert!(prompt.contains("MUST_COVER_FILES=[auth/login.rs (edit, +8/-2)]"));
865        assert!(prompt.contains("\"change_evidence\":"));
866        assert!(prompt.contains("\"path\":\"auth/login.rs\""));
867    }
868
869    #[test]
870    fn build_summary_prompt_injects_rules_when_custom_template_omits_placeholders() {
871        let mut session = make_session("prompt-custom-template-rules");
872        session
873            .events
874            .push(make_event("e-user", EventType::UserMessage, "summarize"));
875        session.recompute_stats();
876
877        let prompt = build_summary_prompt(
878            &session,
879            "session_events".to_string(),
880            vec!["assistant: touched auth guard".to_string()],
881            vec![HailCompactFileChange {
882                path: "src/auth/guard.rs".to_string(),
883                layer: "application".to_string(),
884                operation: "edit".to_string(),
885                lines_added: 3,
886                lines_removed: 1,
887            }],
888            serde_json::Value::Null,
889            SummaryPromptConfig {
890                response_style: SummaryResponseStyle::Compact,
891                output_shape: SummaryOutputShape::FileList,
892                source_mode: SummarySourceMode::SessionOnly,
893                prompt_template: "Use this json only: {{HAIL_COMPACT}}",
894            },
895        );
896
897        assert!(prompt.contains("- Response style: compact."));
898        assert!(prompt.contains("- Output shape: file_list."));
899        assert!(prompt.contains("- Input source mode: session_only."));
900        assert!(prompt.contains("MUST_COVER_FILES=[src/auth/guard.rs (edit, +3/-1)]"));
901        assert!(prompt.contains("\"summary_source\":\"session_events\""));
902    }
903
904    #[test]
905    fn build_summary_prompt_includes_diff_change_evidence_samples() {
906        let mut session = make_session("prompt-evidence");
907        session.events.push(make_event(
908            "edit-auth",
909            EventType::FileEdit {
910                path: "src/auth/guard.rs".to_string(),
911                diff: Some(
912                    "\
913diff --git a/src/auth/guard.rs b/src/auth/guard.rs\n\
914@@ -10,2 +10,3 @@\n\
915-if token == \"\" { return Err(AuthError::MissingToken); }\n\
916+if token.trim().is_empty() { return Err(AuthError::MissingToken); }\n\
917+ensure_valid_token(token)?;\n"
918                        .to_string(),
919                ),
920            },
921            "",
922        ));
923        session.recompute_stats();
924
925        let prompt = build_summary_prompt(
926            &session,
927            "session_events".to_string(),
928            vec!["assistant: tightened auth token validation".to_string()],
929            vec![HailCompactFileChange {
930                path: "src/auth/guard.rs".to_string(),
931                layer: "application".to_string(),
932                operation: "edit".to_string(),
933                lines_added: 2,
934                lines_removed: 1,
935            }],
936            serde_json::Value::Null,
937            SummaryPromptConfig {
938                response_style: SummaryResponseStyle::Detailed,
939                output_shape: SummaryOutputShape::SecurityFirst,
940                source_mode: SummarySourceMode::SessionOnly,
941                prompt_template: DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2,
942            },
943        );
944
945        assert!(prompt.contains("\"change_evidence\":"));
946        assert!(prompt.contains("\"path\":\"src/auth/guard.rs\""));
947        assert!(prompt.contains("\"added_samples\":"));
948        assert!(prompt.contains("ensure_valid_token(token)?;"));
949        assert!(prompt.contains("\"removed_samples\":"));
950    }
951
952    #[test]
953    fn build_summary_prompt_truncates_to_max_chars() {
954        let mut session = make_session("prompt-truncate");
955        session
956            .events
957            .push(make_event("e-user", EventType::UserMessage, "hello"));
958        session.recompute_stats();
959
960        let oversized_timeline = format!("assistant: {}", "x".repeat(14_000));
961        let prompt = build_summary_prompt(
962            &session,
963            "session_events".to_string(),
964            vec![oversized_timeline],
965            vec![HailCompactFileChange {
966                path: "src/main.rs".to_string(),
967                layer: "application".to_string(),
968                operation: "edit".to_string(),
969                lines_added: 1,
970                lines_removed: 0,
971            }],
972            serde_json::Value::Null,
973            SummaryPromptConfig {
974                response_style: SummaryResponseStyle::Standard,
975                output_shape: SummaryOutputShape::Layered,
976                source_mode: SummarySourceMode::SessionOnly,
977                prompt_template: DEFAULT_SUMMARY_PROMPT_TEMPLATE_V2,
978            },
979        );
980
981        assert_eq!(prompt.chars().count(), 16_000);
982    }
983
984    #[test]
985    fn validate_summary_prompt_template_requires_hail_placeholder() {
986        assert!(validate_summary_prompt_template("hello").is_err());
987        assert!(validate_summary_prompt_template("{{HAIL_COMPACT}}").is_ok());
988    }
989}