1use crate::git_store::{CommitInfo, GitStore};
2use crate::llm::Llm;
3use crate::manifest::Manifest;
4use crate::types::{Action, Actor, DocType};
5use anyhow::Result;
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{LazyLock, Mutex};
11
12static REFRESH_IN_FLIGHT: LazyLock<Mutex<HashMap<PathBuf, bool>>> =
13 LazyLock::new(|| Mutex::new(HashMap::new()));
14
15const EVENTS_FILE: &str = ".agent-trace/summary_events.jsonl";
16const SUMMARY_STATE_FILE: &str = ".agent-trace/summary_state.toml";
17const RUNNING_SUMMARY_FILE: &str = "running_summary.md";
18const MAX_EVENTS_RETAINED: usize = 500;
19const RECENT_ACTIVITY_LIMIT: usize = 20;
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
22pub struct SummaryState {
23 #[serde(default)]
24 pub events_count_at_template_refresh: usize,
25 #[serde(default)]
26 pub events_count_at_synthesis_refresh: usize,
27 #[serde(default)]
28 pub ops_since_synthesis: usize,
29 #[serde(default)]
30 pub events_count_at_history_summary: usize,
31}
32
33#[derive(Debug, Clone, Deserialize, Default)]
34struct SummaryStateRaw {
35 #[serde(default)]
36 events_count_at_template_refresh: usize,
37 #[serde(default)]
38 events_count_at_synthesis_refresh: usize,
39 #[serde(default)]
40 ops_since_synthesis: usize,
41 #[serde(default)]
42 events_count_at_refresh: usize,
43 #[serde(default)]
44 ops_since_refresh: usize,
45 #[serde(default)]
46 events_count_at_history_summary: usize,
47}
48
49fn migrate_summary_state(raw: SummaryStateRaw) -> SummaryState {
50 let mut state = SummaryState {
51 events_count_at_template_refresh: raw.events_count_at_template_refresh,
52 events_count_at_synthesis_refresh: raw.events_count_at_synthesis_refresh,
53 ops_since_synthesis: raw.ops_since_synthesis,
54 events_count_at_history_summary: raw.events_count_at_history_summary,
55 };
56 if state.events_count_at_template_refresh == 0
57 && state.events_count_at_synthesis_refresh == 0
58 && raw.events_count_at_refresh > 0
59 {
60 state.events_count_at_template_refresh = raw.events_count_at_refresh;
61 state.events_count_at_synthesis_refresh = raw.events_count_at_refresh;
62 }
63 if state.ops_since_synthesis == 0 && raw.ops_since_refresh > 0 {
64 state.ops_since_synthesis = raw.ops_since_refresh;
65 }
66 state
67}
68
69pub fn summary_state_path(store_root: &Path) -> PathBuf {
70 store_root.join(SUMMARY_STATE_FILE)
71}
72
73pub fn load_summary_state(store_root: &Path) -> Result<SummaryState> {
74 let path = summary_state_path(store_root);
75 if !path.exists() {
76 return Ok(SummaryState::default());
77 }
78 let content = std::fs::read_to_string(&path)?;
79 let raw: SummaryStateRaw = toml::from_str(&content).unwrap_or_default();
80 Ok(migrate_summary_state(raw))
81}
82
83pub fn save_summary_state(store_root: &Path, state: &SummaryState) -> Result<()> {
84 let path = summary_state_path(store_root);
85 if let Some(parent) = path.parent() {
86 std::fs::create_dir_all(parent)?;
87 }
88 let content = toml::to_string_pretty(state)?;
89 crate::util::atomic_write(&path, &content)?;
90 Ok(())
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
94pub struct SummaryEvent {
95 pub timestamp: String,
96 pub session_id: Option<String>,
97 pub agent_name: Option<String>,
98 pub actor: String,
99 pub action: String,
100 #[serde(default)]
101 pub change_kind: String,
102 pub path: String,
103 pub doc_type: String,
104 pub summary: String,
105 pub source: String,
106 #[serde(default)]
107 pub detected_by: String,
108 pub lines_added: usize,
109 pub lines_removed: usize,
110}
111
112pub fn events_path(store_root: &Path) -> PathBuf {
113 store_root.join(EVENTS_FILE)
114}
115
116pub fn append_event(store_root: &Path, event: SummaryEvent) -> Result<()> {
117 let path = events_path(store_root);
118 if let Some(parent) = path.parent() {
119 std::fs::create_dir_all(parent)?;
120 }
121 let mut events = load_all_events(store_root)?;
122 if let Some(last) = events.last() {
123 if is_near_duplicate(last, &event) {
124 tracing::debug!(
125 "skipping duplicate activity event for {} ({})",
126 event.path,
127 event.change_kind
128 );
129 return Ok(());
130 }
131 }
132 increment_synthesis_ops(store_root)?;
133 events.push(event);
134 if events.len() > MAX_EVENTS_RETAINED {
135 let skip = events.len() - MAX_EVENTS_RETAINED;
136 events = events.split_off(skip);
137 }
138 let content = events
139 .iter()
140 .map(|e| serde_json::to_string(e).unwrap_or_default())
141 .collect::<Vec<_>>()
142 .join("\n");
143 let content = if content.is_empty() {
144 String::new()
145 } else {
146 content + "\n"
147 };
148 std::fs::write(&path, content)?;
149 Ok(())
150}
151
152fn is_near_duplicate(last: &SummaryEvent, event: &SummaryEvent) -> bool {
153 if last.path != event.path {
154 return false;
155 }
156 let kind = if event.change_kind.is_empty() {
157 &event.action
158 } else {
159 &event.change_kind
160 };
161 let last_kind = if last.change_kind.is_empty() {
162 &last.action
163 } else {
164 &last.change_kind
165 };
166 if last_kind != kind {
167 return false;
168 }
169 if last.detected_by.is_empty()
171 || event.detected_by.is_empty()
172 || last.detected_by == event.detected_by
173 {
174 return false;
175 }
176 let Ok(t1) = chrono::DateTime::parse_from_rfc3339(&last.timestamp) else {
177 return false;
178 };
179 let Ok(t2) = chrono::DateTime::parse_from_rfc3339(&event.timestamp) else {
180 return false;
181 };
182 (t2 - t1).num_seconds().abs() <= 5
183}
184
185pub fn load_all_events(store_root: &Path) -> Result<Vec<SummaryEvent>> {
186 let path = events_path(store_root);
187 if !path.exists() {
188 return Ok(Vec::new());
189 }
190 let content = std::fs::read_to_string(&path)?;
191 Ok(content
192 .lines()
193 .filter(|l| !l.trim().is_empty())
194 .filter_map(|l| serde_json::from_str(l).ok())
195 .collect())
196}
197
198pub fn event_count(store_root: &Path) -> Result<usize> {
199 Ok(load_all_events(store_root)?.len())
200}
201
202fn save_template_watermark(store_root: &Path, event_count: usize) -> Result<()> {
203 let mut state = load_summary_state(store_root)?;
204 state.events_count_at_template_refresh = event_count;
205 save_summary_state(store_root, &state)
206}
207
208fn save_synthesis_watermark(store_root: &Path, event_count: usize) -> Result<()> {
209 let mut state = load_summary_state(store_root)?;
210 state.events_count_at_synthesis_refresh = event_count;
211 state.ops_since_synthesis = 0;
212 save_summary_state(store_root, &state)
213}
214
215pub fn increment_synthesis_ops(store_root: &Path) -> Result<usize> {
216 let mut state = load_summary_state(store_root)?;
217 state.ops_since_synthesis += 1;
218 let n = state.ops_since_synthesis;
219 save_summary_state(store_root, &state)?;
220 Ok(n)
221}
222
223pub fn synthesis_refresh_threshold(store_root: &Path) -> usize {
224 refresh_threshold(store_root)
225}
226
227fn refresh_threshold(store_root: &Path) -> usize {
228 crate::config::MergedConfig::load(store_root)
229 .map(|c| c.synthesis.refresh_every_ops)
230 .unwrap_or(10)
231 .max(1)
232}
233
234pub fn load_recent_events(store_root: &Path, limit: usize) -> Result<Vec<SummaryEvent>> {
235 let mut events = load_all_events(store_root)?;
236 if events.len() > limit {
237 let skip = events.len() - limit;
238 events = events.split_off(skip);
239 }
240 Ok(events)
241}
242
243pub fn format_events_for_prompt(events: &[SummaryEvent]) -> String {
244 events
245 .iter()
246 .map(|e| format!("[{}] {} {} — {}", e.timestamp, e.action, e.path, e.summary))
247 .collect::<Vec<_>>()
248 .join("\n")
249}
250
251pub fn read_plan_snippet(store_root: &Path, manifest: &Manifest) -> String {
252 let plans = manifest.list(Some(&DocType::Plan));
253 let plan_path = plans
254 .first()
255 .map(|p| p.path.clone())
256 .unwrap_or_else(|| PathBuf::from("plan.md"));
257 let content = std::fs::read_to_string(store_root.join(&plan_path)).unwrap_or_default();
258 content.chars().take(2000).collect()
259}
260
261fn extract_resume_from_plan(plan_snippet: &str) -> String {
262 for line in plan_snippet.lines() {
263 let trimmed = line.trim();
264 if trimmed.starts_with("- [ ]")
265 || trimmed.to_lowercase().contains("phase")
266 || trimmed.to_uppercase().contains("RESUME")
267 {
268 return trimmed.to_string();
269 }
270 }
271 "Review plan.md for next steps.".to_string()
272}
273
274pub fn synthesize_template_summary(
275 store_root: &Path,
276 manifest: &Manifest,
277 events: &[SummaryEvent],
278) -> Result<String> {
279 let plan_snippet = read_plan_snippet(store_root, manifest);
280 let resume = extract_resume_from_plan(&plan_snippet);
281 let now = Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
282 let session = events
283 .last()
284 .and_then(|e| e.agent_name.as_deref())
285 .unwrap_or("system");
286 let session_id = events
287 .last()
288 .and_then(|e| e.session_id.as_deref())
289 .unwrap_or("—");
290
291 let mut out = String::from("# Running Summary\n\n");
292 out.push_str(&format!(
293 "*Last updated: {now} by agent-trace*\n\
294 *Session: {session} / {session_id}*\n\n"
295 ));
296
297 out.push_str("## Current Status\n\n");
298 if plan_snippet.is_empty() {
299 out.push_str(
300 "No plan document tracked yet. Add a plan via `agent-trace add plan plan.md`.\n\n",
301 );
302 } else {
303 let status: String = plan_snippet.chars().take(500).collect();
304 out.push_str(&status);
305 out.push_str("\n\n");
306 }
307
308 out.push_str("## Recent Activity (rolling)\n\n");
309 if events.is_empty() {
310 out.push_str("*(no activity yet)*\n\n");
311 } else {
312 let recent: Vec<_> = events.iter().rev().take(RECENT_ACTIVITY_LIMIT).collect();
313 for e in recent {
314 let time = e
315 .timestamp
316 .split('T')
317 .nth(1)
318 .and_then(|t| t.split('Z').next())
319 .unwrap_or(&e.timestamp);
320 out.push_str(&format!("- [{time}] {}: {}\n", e.path, e.summary));
321 }
322 out.push('\n');
323 }
324
325 out.push_str("## Resume Here\n\n");
326 out.push_str(&resume);
327 out.push_str("\n\n");
328
329 out.push_str("## Key Documents\n\n");
330 for p in manifest.list(Some(&DocType::Plan)) {
331 out.push_str(&format!("- {} — phase checklist\n", p.path.display()));
332 }
333 for p in manifest.list(Some(&DocType::Scratch)) {
334 if p.path.to_string_lossy().contains("progress") {
335 out.push_str(&format!("- {} — detailed progress\n", p.path.display()));
336 }
337 }
338 if manifest.list(Some(&DocType::Plan)).is_empty() {
339 out.push_str("*(no plan documents)*\n");
340 }
341 out.push('\n');
342
343 out.push_str("## Open Items\n\n");
344 for line in plan_snippet.lines() {
345 let trimmed = line.trim();
346 if trimmed.starts_with("- [ ]") {
347 out.push_str(&format!("{trimmed}\n"));
348 }
349 }
350 if !plan_snippet.contains("- [ ]") {
351 out.push_str("*(see plan.md for open items)*\n");
352 }
353
354 Ok(out)
355}
356
357pub fn write_running_summary(
358 store_root: &Path,
359 content: &str,
360 git: &GitStore,
361 manifest: &mut Manifest,
362 commit_label: &str,
363) -> Result<()> {
364 let rel = PathBuf::from(RUNNING_SUMMARY_FILE);
365 let existed = store_root.join(&rel).exists();
366 std::fs::write(store_root.join(&rel), content)?;
367
368 if !manifest.is_tracked(&rel) {
369 manifest.register(&rel, DocType::Context, "")?;
370 manifest.save(store_root)?;
371 }
372
373 let action = if existed {
374 Action::Modify
375 } else {
376 Action::Create
377 };
378 let info = CommitInfo {
379 action: action.clone(),
380 files: vec![(rel, action, DocType::Context)],
381 actor: Actor::System,
382 summary: format!("refresh running summary ({commit_label})"),
383 agent_name: None,
384 session_id: None,
385 };
386 git.commit(&info)?;
387 Ok(())
388}
389
390pub fn refresh_template(store_root: &Path, git: &GitStore, manifest: &Manifest) -> Result<()> {
391 let events = load_recent_events(store_root, 50)?;
392 let content = synthesize_template_summary(store_root, manifest, &events)?;
393 let mut manifest_mut = Manifest::load(store_root)?;
394 write_running_summary(store_root, &content, git, &mut manifest_mut, "template")?;
395 save_template_watermark(store_root, event_count(store_root)?)
396}
397
398pub fn refresh(store_root: &Path, git: &GitStore, manifest: &Manifest) -> Result<()> {
399 let events = load_recent_events(store_root, 50)?;
400 let previous =
401 std::fs::read_to_string(store_root.join(RUNNING_SUMMARY_FILE)).unwrap_or_default();
402 let plan_snippet = read_plan_snippet(store_root, manifest);
403 let events_str = format_events_for_prompt(&events);
404
405 let api = Llm::from_store_root(store_root).map_err(|e| anyhow::anyhow!(e))?;
406 let start = std::time::Instant::now();
407 let used_llm = !api.is_degraded();
408 let (content, commit_label) = if used_llm {
409 match api.update_running_summary(&previous, &events_str, &plan_snippet) {
410 Ok(s) => {
411 tracing::info!(
412 "LLM running summary synthesis succeeded (backend={}, latency_ms={})",
413 api.backend_label,
414 start.elapsed().as_millis()
415 );
416 (s, api.backend_label.clone())
417 }
418 Err(e) => {
419 tracing::warn!("synthesis running summary failed: {e}");
420 (
421 synthesize_template_summary(store_root, manifest, &events)?,
422 "template".into(),
423 )
424 }
425 }
426 } else {
427 (
428 synthesize_template_summary(store_root, manifest, &events)?,
429 "template".into(),
430 )
431 };
432
433 let mut manifest_mut = Manifest::load(store_root)?;
434 write_running_summary(store_root, &content, git, &mut manifest_mut, &commit_label)?;
435 save_synthesis_watermark(store_root, event_count(store_root)?)
436}
437
438pub fn refresh_from_path(store_root: &Path) -> Result<()> {
439 let git = GitStore::open(store_root)?;
440 let manifest = Manifest::load(store_root)?;
441 refresh(store_root, &git, &manifest)
442}
443
444#[doc(hidden)]
445pub fn wait_refresh_idle(store_root: &Path) {
446 for _ in 0..150 {
447 let busy = REFRESH_IN_FLIGHT
448 .lock()
449 .expect("refresh lock poisoned")
450 .get(&store_root.to_path_buf())
451 .copied()
452 .unwrap_or(false);
453 if !busy {
454 let current = event_count(store_root).unwrap_or(0);
455 let watermark = load_summary_state(store_root)
456 .map(|s| s.events_count_at_synthesis_refresh)
457 .unwrap_or(0);
458 if current <= watermark {
459 return;
460 }
461 }
462 std::thread::sleep(std::time::Duration::from_millis(20));
463 }
464}
465
466pub fn schedule_synthesis_refresh(store_root: PathBuf) {
467 schedule_synthesis_refresh_inner(store_root, false);
468}
469
470fn schedule_synthesis_refresh_inner(store_root: PathBuf, force: bool) {
471 let threshold = refresh_threshold(&store_root);
472 if !force {
473 let ops = load_summary_state(&store_root)
474 .map(|s| s.ops_since_synthesis)
475 .unwrap_or(0);
476 if ops < threshold {
477 return;
478 }
479 }
480
481 let should_spawn = {
482 let mut in_flight = REFRESH_IN_FLIGHT.lock().expect("refresh lock poisoned");
483 if *in_flight.get(&store_root).unwrap_or(&false) {
484 false
485 } else {
486 in_flight.insert(store_root.clone(), true);
487 true
488 }
489 };
490 if !should_spawn {
491 return;
492 }
493
494 std::thread::spawn(move || {
495 let events_before = event_count(&store_root).unwrap_or(0);
496 if let Err(e) = refresh_from_path(&store_root) {
497 tracing::warn!("running summary background refresh failed: {e}");
498 } else {
499 if let Err(e) = crate::briefing::maybe_refresh_history_summary(&store_root, true) {
500 tracing::warn!("history summary refresh failed: {e}");
501 }
502 if let Some(sid) = crate::session::session_id_for_store(&store_root) {
503 let _ =
504 crate::session_checkpoint::maybe_write_session_checkpoint(&store_root, &sid);
505 }
506 }
507 let events_after = event_count(&store_root).unwrap_or(events_before);
508 let watermark = load_summary_state(&store_root)
509 .map(|s| s.events_count_at_synthesis_refresh)
510 .unwrap_or(0);
511 REFRESH_IN_FLIGHT
512 .lock()
513 .expect("refresh lock poisoned")
514 .insert(store_root.clone(), false);
515 let pending_ops = load_summary_state(&store_root)
516 .map(|s| s.ops_since_synthesis)
517 .unwrap_or(0);
518 if events_after > watermark {
519 schedule_synthesis_refresh_inner(store_root.clone(), true);
520 } else if pending_ops >= threshold {
521 schedule_synthesis_refresh_inner(store_root, false);
522 }
523 });
524}
525
526pub fn refresh_if_stale(store_root: &Path) -> Result<()> {
527 let summary_path = store_root.join(RUNNING_SUMMARY_FILE);
528 let current_count = event_count(store_root)?;
529 if current_count == 0 {
530 return Ok(());
531 }
532 let state = load_summary_state(store_root)?;
533 if !summary_path.exists() || current_count > state.events_count_at_template_refresh {
534 let git = GitStore::open(store_root)?;
535 let manifest = Manifest::load(store_root)?;
536 refresh_template(store_root, &git, &manifest)?;
537 }
538 let state = load_summary_state(store_root)?;
539 if state.ops_since_synthesis > 0 || current_count > state.events_count_at_synthesis_refresh {
540 schedule_synthesis_refresh(store_root.to_path_buf());
541 }
542 Ok(())
543}
544
545pub fn resume_here_lines(store_root: &Path) -> Vec<String> {
546 let path = store_root.join(RUNNING_SUMMARY_FILE);
547 if !path.exists() {
548 return Vec::new();
549 }
550 let content = std::fs::read_to_string(path).unwrap_or_default();
551 let mut in_section = false;
552 let mut lines = Vec::new();
553 for line in content.lines() {
554 if line.starts_with("## Resume Here") {
555 in_section = true;
556 continue;
557 }
558 if in_section {
559 if line.starts_with("## ") {
560 break;
561 }
562 if !line.trim().is_empty() {
563 lines.push(line.to_string());
564 if lines.len() >= 3 {
565 break;
566 }
567 }
568 }
569 }
570 lines
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::briefing::{assemble_resume_briefing, BriefingOptions};
577 use crate::config::StoreInfo;
578 use crate::session;
579 use crate::types::Actor;
580 use tempfile::TempDir;
581
582 fn setup(tmp: &TempDir) -> (PathBuf, Manifest, GitStore) {
583 let root = tmp.path().to_path_buf();
584 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
585 let git = GitStore::init(&root).unwrap();
586 let info = StoreInfo::new("test".into());
587 let manifest = Manifest::create_empty(info.clone(), &root).unwrap();
588 let store_cfg = crate::config::StoreConfig {
589 store: info,
590 llm: None,
591 synthesis: Some(crate::config::SynthesisConfig::for_unit_tests_degraded()),
592 polling: crate::config::PollingConfig::default(),
593 };
594 store_cfg.save(&root).unwrap();
595 (root, manifest, git)
596 }
597
598 #[test]
599 fn append_event_creates_jsonl() {
600 let tmp = TempDir::new().unwrap();
601 let (root, _, _) = setup(&tmp);
602 let event = SummaryEvent {
603 timestamp: Utc::now().to_rfc3339(),
604 session_id: Some("20260605-120000".into()),
605 agent_name: Some("claude".into()),
606 actor: "agent:claude".into(),
607 action: "modify".into(),
608 path: "plan.md".into(),
609 doc_type: "plan".into(),
610 summary: "Updated phase 2".into(),
611 source: "mcp_write".into(),
612 detected_by: "mcp".into(),
613 lines_added: 5,
614 lines_removed: 1,
615 change_kind: "modify".into(),
616 };
617 append_event(&root, event).unwrap();
618 assert!(events_path(&root).exists());
619 let events = load_recent_events(&root, 10).unwrap();
620 assert_eq!(events.len(), 1);
621 assert_eq!(events[0].path, "plan.md");
622 }
623
624 #[test]
625 fn template_summary_includes_recent_events() {
626 let tmp = TempDir::new().unwrap();
627 let (root, manifest, _) = setup(&tmp);
628 std::fs::write(
629 root.join("plan.md"),
630 "# Plan\n- [x] Phase 1\n- [ ] Phase 2 idempotency\n",
631 )
632 .unwrap();
633 let mut m = manifest;
634 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
635 .unwrap();
636
637 let events = vec![SummaryEvent {
638 timestamp: "2026-06-05T20:58:09Z".into(),
639 session_id: Some("s1".into()),
640 agent_name: Some("claude".into()),
641 actor: "agent:claude".into(),
642 action: "modify".into(),
643 path: "plan.md".into(),
644 doc_type: "plan".into(),
645 summary: "Phase 2 complete".into(),
646 source: "mcp_write".into(),
647 detected_by: "mcp".into(),
648 lines_added: 3,
649 lines_removed: 0,
650 change_kind: "modify".into(),
651 }];
652 let summary = synthesize_template_summary(&root, &m, &events).unwrap();
653 assert!(summary.contains("# Running Summary"));
654 assert!(summary.contains("Phase 2 complete"));
655 assert!(summary.contains("## Resume Here"));
656 assert!(summary.contains("Phase 2 idempotency"));
657 }
658
659 #[test]
660 fn write_running_summary_commits_and_registers() {
661 let tmp = TempDir::new().unwrap();
662 let (root, manifest, git) = setup(&tmp);
663 let mut m = manifest;
664 let content = "# Running Summary\n\ntest\n";
665 write_running_summary(&root, content, &git, &mut m, "template").unwrap();
666 assert!(root.join(RUNNING_SUMMARY_FILE).exists());
667 assert!(m.is_tracked(&PathBuf::from(RUNNING_SUMMARY_FILE)));
668 assert_eq!(
669 m.find_by_path(&PathBuf::from(RUNNING_SUMMARY_FILE))
670 .unwrap()
671 .doc_type,
672 DocType::Context
673 );
674 }
675
676 #[test]
677 fn refresh_without_llm_writes_template() {
678 let tmp = TempDir::new().unwrap();
679 let (root, manifest, git) = setup(&tmp);
680 std::fs::write(root.join("plan.md"), "# Plan\n- [ ] Next step\n").unwrap();
681 let mut m = manifest;
682 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
683 .unwrap();
684 append_event(
685 &root,
686 SummaryEvent {
687 timestamp: Utc::now().to_rfc3339(),
688 session_id: Some("s1".into()),
689 agent_name: Some("bot".into()),
690 actor: "agent:bot".into(),
691 action: "modify".into(),
692 path: "plan.md".into(),
693 doc_type: "plan".into(),
694 summary: "updated plan".into(),
695 source: "mcp_write".into(),
696 detected_by: "mcp".into(),
697 lines_added: 2,
698 lines_removed: 0,
699 change_kind: "modify".into(),
700 },
701 )
702 .unwrap();
703 refresh(&root, &git, &m).unwrap();
704 let content = std::fs::read_to_string(root.join(RUNNING_SUMMARY_FILE)).unwrap();
705 assert!(content.contains("# Running Summary"));
706 assert!(content.contains("updated plan"));
707 }
708
709 fn sample_event(path: &str, summary: &str) -> SummaryEvent {
710 SummaryEvent {
711 timestamp: Utc::now().to_rfc3339(),
712 session_id: Some("s1".into()),
713 agent_name: Some("bot".into()),
714 actor: "agent:bot".into(),
715 action: "modify".into(),
716 path: path.into(),
717 doc_type: "scratch".into(),
718 summary: summary.into(),
719 source: "mcp_write".into(),
720 detected_by: "mcp".into(),
721 lines_added: 1,
722 lines_removed: 0,
723 change_kind: "modify".into(),
724 }
725 }
726
727 #[test]
728 fn refresh_if_stale_refreshes_when_events_exceed_watermark() {
729 let tmp = TempDir::new().unwrap();
730 let (root, manifest, git) = setup(&tmp);
731 std::fs::write(root.join("plan.md"), "# Plan\n- [ ] Next\n").unwrap();
732 let mut m = manifest;
733 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
734 .unwrap();
735 append_event(&root, sample_event("plan.md", "first event")).unwrap();
736 refresh_template(&root, &git, &m).unwrap();
737 assert_eq!(
738 load_summary_state(&root)
739 .unwrap()
740 .events_count_at_template_refresh,
741 1
742 );
743
744 append_event(&root, sample_event("notes.md", "second event")).unwrap();
745 refresh_if_stale(&root).unwrap();
746
747 assert_eq!(
748 load_summary_state(&root)
749 .unwrap()
750 .events_count_at_template_refresh,
751 2
752 );
753 let summary = std::fs::read_to_string(root.join(RUNNING_SUMMARY_FILE)).unwrap();
754 assert!(summary.contains("second event"));
755 }
756
757 #[test]
758 fn refresh_if_stale_skips_when_watermark_is_current() {
759 let tmp = TempDir::new().unwrap();
760 let (root, manifest, git) = setup(&tmp);
761 std::fs::write(root.join("plan.md"), "# Plan\n").unwrap();
762 let mut m = manifest;
763 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
764 .unwrap();
765 append_event(&root, sample_event("plan.md", "only event")).unwrap();
766 refresh_template(&root, &git, &m).unwrap();
767 let before = std::fs::read_to_string(root.join(RUNNING_SUMMARY_FILE)).unwrap();
768
769 refresh_if_stale(&root).unwrap();
770
771 let after = std::fs::read_to_string(root.join(RUNNING_SUMMARY_FILE)).unwrap();
772 assert_eq!(before, after);
773 }
774
775 #[test]
776 fn template_refresh_every_write_does_not_reset_synthesis_ops() {
777 let tmp = TempDir::new().unwrap();
778 let (root, manifest, git) = setup(&tmp);
779 std::fs::write(root.join("plan.md"), "# Plan\n- [ ] Next\n").unwrap();
780 let mut m = manifest;
781 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
782 .unwrap();
783
784 for i in 0..3 {
785 append_event(&root, sample_event("plan.md", &format!("event {i}"))).unwrap();
786 refresh_template(&root, &git, &m).unwrap();
787 }
788
789 assert_eq!(load_summary_state(&root).unwrap().ops_since_synthesis, 3);
790 assert_eq!(
791 load_summary_state(&root)
792 .unwrap()
793 .events_count_at_template_refresh,
794 3
795 );
796 }
797
798 #[test]
799 fn synthesis_refresh_resets_ops_at_threshold() {
800 let tmp = TempDir::new().unwrap();
801 let (root, manifest, git) = setup(&tmp);
802 std::fs::write(root.join("plan.md"), "# Plan\n- [ ] Next\n").unwrap();
803 let mut m = manifest;
804 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
805 .unwrap();
806
807 for i in 0..10 {
808 append_event(&root, sample_event("plan.md", &format!("event {i}"))).unwrap();
809 }
810 assert_eq!(load_summary_state(&root).unwrap().ops_since_synthesis, 10);
811
812 refresh(&root, &git, &m).unwrap();
813 let state = load_summary_state(&root).unwrap();
814 assert_eq!(state.ops_since_synthesis, 0);
815 assert_eq!(state.events_count_at_synthesis_refresh, 10);
816 }
817
818 #[test]
819 fn schedule_synthesis_refresh_reschedules_when_events_arrive_during_refresh() {
820 let tmp = TempDir::new().unwrap();
821 let (root, manifest, git) = setup(&tmp);
822 let store_cfg = crate::config::StoreConfig {
823 store: manifest.store.clone(),
824 llm: None,
825 synthesis: Some(crate::config::SynthesisConfig {
826 refresh_every_ops: 1,
827 ..crate::config::SynthesisConfig::for_unit_tests_degraded()
828 }),
829 polling: crate::config::PollingConfig::default(),
830 };
831 store_cfg.save(&root).unwrap();
832 std::fs::write(root.join("plan.md"), "# Plan\n- [ ] Next\n").unwrap();
833 let mut m = manifest;
834 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
835 .unwrap();
836 append_event(&root, sample_event("plan.md", "seed event")).unwrap();
837 refresh_template(&root, &git, &m).unwrap();
838 append_event(&root, sample_event("plan.md", "pre-refresh event")).unwrap();
839
840 wait_refresh_idle(&root);
841 schedule_synthesis_refresh(root.clone());
842 append_event(&root, sample_event("notes.md", "late event")).unwrap();
843
844 wait_refresh_idle(&root);
845 refresh_if_stale(&root).unwrap();
846
847 let expected_events = event_count(&root).unwrap();
848 assert_eq!(
849 load_summary_state(&root)
850 .unwrap()
851 .events_count_at_synthesis_refresh,
852 expected_events
853 );
854 let summary = std::fs::read_to_string(root.join(RUNNING_SUMMARY_FILE)).unwrap();
855 assert!(
856 summary.contains("late event"),
857 "summary missing late event:\n{summary}"
858 );
859 }
860
861 #[test]
862 fn assemble_resume_briefing_includes_current_checkpoint() {
863 let tmp = TempDir::new().unwrap();
864 let (root, manifest, git) = setup(&tmp);
865 let mut m = manifest;
866 std::fs::write(root.join("plan.md"), "# Plan\n\n## Goal\n\nTest goal.\n").unwrap();
867 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
868 .unwrap();
869 write_running_summary(
870 &root,
871 "# Running Summary\n\n## Resume Here\n\nContinue\n",
872 &git,
873 &mut m,
874 "template",
875 )
876 .unwrap();
877 let sess = session::start_session(&root, "bot", "cli").unwrap();
878 crate::session_checkpoint::persist_checkpoint(
879 &root,
880 &sess.session_id,
881 "# Current Session Checkpoint\n\n*Agent: bot / session (cli)*\n\nMid-session work.\n",
882 )
883 .unwrap();
884
885 let text = assemble_resume_briefing(
886 &root,
887 &Actor::Agent { name: "bot".into() },
888 &BriefingOptions {
889 include_git_log: false,
890 git_log_limit: 5,
891 ..Default::default()
892 },
893 )
894 .unwrap();
895 assert!(text.contains("## 2. Current State"));
896 assert!(text.contains("INSTRUCTIONS"));
897 }
898
899 #[test]
900 fn assemble_resume_briefing_includes_prior_session_recap() {
901 let tmp = TempDir::new().unwrap();
902 let (root, manifest, git) = setup(&tmp);
903 let mut m = manifest;
904 std::fs::write(root.join("plan.md"), "# Plan\n\n## Goal\n\nTest goal.\n").unwrap();
905 m.register(&PathBuf::from("plan.md"), DocType::Plan, "")
906 .unwrap();
907 write_running_summary(
908 &root,
909 "# Running Summary\n\n## Resume Here\n\nContinue\n",
910 &git,
911 &mut m,
912 "template",
913 )
914 .unwrap();
915 crate::session_recap::persist_session_recap(
916 &root,
917 "prior-session",
918 "# Prior Session Recap\n\n*Agent: bot / prior-session (cli)*\n\nFinished phase 1.\n",
919 )
920 .unwrap();
921 session::start_session(&root, "bot", "cli").unwrap();
922
923 let text = assemble_resume_briefing(
924 &root,
925 &Actor::Agent { name: "bot".into() },
926 &BriefingOptions {
927 include_git_log: false,
928 git_log_limit: 5,
929 ..Default::default()
930 },
931 )
932 .unwrap();
933 assert!(text.contains("Previous session:"));
934 assert!(text.contains("Finished phase 1"));
935 assert!(!text.contains("--- Running Summary ---"));
936 }
937
938 #[test]
939 fn events_retention_truncates_old() {
940 let tmp = TempDir::new().unwrap();
941 let (root, _, _) = setup(&tmp);
942 for i in 0..MAX_EVENTS_RETAINED + 10 {
943 append_event(
944 &root,
945 SummaryEvent {
946 timestamp: format!("2026-06-05T00:{i:02}Z"),
947 session_id: None,
948 agent_name: None,
949 actor: "system".into(),
950 action: "modify".into(),
951 path: format!("f{i}.md"),
952 doc_type: "scratch".into(),
953 summary: format!("event {i}"),
954 source: "poll".into(),
955 detected_by: "poll".into(),
956 lines_added: 0,
957 lines_removed: 0,
958 change_kind: "modify".into(),
959 },
960 )
961 .unwrap();
962 }
963 let events = load_all_events(&root).unwrap();
964 assert_eq!(events.len(), MAX_EVENTS_RETAINED);
965 assert_eq!(events[0].path, "f10.md");
966 }
967}