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 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}