Skip to main content

agent_trace/trace/
running_summary.rs

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    // Only suppress cross-source duplicates (e.g. MCP commit + poll detecting same file).
170    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}