Skip to main content

fno_agents/
digest.rs

1//! `fno-agents digest` — the "while you were gone" fold (x-4e2d).
2//!
3//! A pure read-time fold: given a session and a `--since` timestamp, summarize
4//! what happened by reading events.jsonl (gate/loop signal) and ledger.json
5//! (cost + PR). NO new storage — every fact already persists for other reasons.
6//!
7//! Two event envelopes coexist in the same file and both are read:
8//!   - Branch A (Python target/hooks): nested `{"type","source","data":{...}}`.
9//!     Carries the interesting signal — `loop_check` (pr_state/ci/reviewed/
10//!     fingerprint/decision) and `termination` (reason). Filtered by
11//!     `data.session_id`.
12//!   - Branch B (Rust daemon): flat `{"ts","kind","source",...}`. Lifecycle
13//!     only; matched by a flat `session_id` when present.
14//!
15//! Commit count is derived from HEAD-sha transitions in the `loop_check`
16//! fingerprint (`HEAD|pr_state|ci|review_ts`), since events never carry a
17//! commit event directly. PR url/number and cost come from the ledger, not
18//! events. Blocked episodes come from `loop_check` `decision:"block"`; "who
19//! answered" is not stamped anywhere (see SUMMARY follow-up), so resolution is
20//! reported as resolved-or-not (a later non-block decision for the session),
21//! not by author.
22//!
23//! The `--since` bound has two forms (see [`Since`]): the CLI `--since <ts>`
24//! compares lexicographically (correct for the Z-suffixed RFC3339 in the log,
25//! no date math), while `--since-epoch <secs>` — what the attach overlay passes
26//! so it can hand the detach time without synthesizing an RFC3339 string —
27//! parses each row's ts to epoch seconds for a numeric compare.
28
29use crate::paths::AgentsHome;
30use serde::Serialize;
31use serde_json::Value;
32use std::collections::HashSet;
33use std::path::{Path, PathBuf};
34
35/// The typed fold result. Field order is the JSON key order (serde serializes
36/// in declaration order); the client reads `lines` to render the overlay.
37#[derive(Debug, Serialize, PartialEq)]
38pub struct DigestSummary {
39    pub session: String,
40    pub since: String,
41    pub commits: usize,
42    pub pr_number: Option<u64>,
43    pub pr_url: Option<String>,
44    pub pr_state: Option<String>,
45    pub ci: Option<String>,
46    pub reviewed: bool,
47    pub blocked_episodes: usize,
48    pub last_block_reason: Option<String>,
49    pub resolved: bool,
50    pub cost_usd: f64,
51    /// Derived current state: `done` if terminated, else `blocked` if the last
52    /// decision blocked and was not followed by an allow, else `working`.
53    pub state: String,
54    /// Count of events.jsonl lines that failed to parse (AC-error).
55    pub skipped_lines: usize,
56    /// Pre-rendered ranked human lines (attention first). Both the human text
57    /// output and the JSON `lines` field share this, so formatting lives once.
58    pub lines: Vec<String>,
59}
60
61/// Read `<field>` from an event value regardless of envelope: the unified
62/// shape nests under `/data`, the retired flat shape is top-level. The flat
63/// fallback covers the mixed-binary + rotated-history window (x-2901); drop it
64/// once the daemon fleet has restarted on the post-cut binary.
65fn field<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
66    v.get("data")
67        .and_then(|d| d.get(key))
68        .or_else(|| v.get(key))
69}
70
71/// The event's `type` (unified) with a `kind` fallback for retired flat lines
72/// during the mixed-binary window (x-2901); drop the fallback post-cut.
73fn event_kind(v: &Value) -> Option<&str> {
74    v.get("type")
75        .and_then(|t| t.as_str())
76        .or_else(|| v.get("kind").and_then(|k| k.as_str()))
77}
78
79/// The event timestamp; `""` if absent.
80fn event_ts(v: &Value) -> &str {
81    v.get("ts").and_then(|t| t.as_str()).unwrap_or("")
82}
83
84/// The `--since` bound. `Epoch` (what the attach overlay passes: the detach
85/// time in epoch seconds) parses each row's RFC3339 ts to epoch and compares
86/// numerically, so the digest scopes to the absence window without the caller
87/// synthesizing an RFC3339 string. `Str` is the CLI `--since <ts>` form,
88/// compared lexicographically (correct for the Z-suffixed RFC3339 in the log).
89#[derive(Clone, Copy)]
90enum Since<'a> {
91    Str(&'a str),
92    Epoch(u64),
93}
94
95impl Since<'_> {
96    /// Is a row with timestamp `ts` inside the window? An undated/unparseable
97    /// row is included (never silently dropped by the bound).
98    fn includes(&self, ts: &str) -> bool {
99        match self {
100            Since::Str(s) => ts >= *s,
101            Since::Epoch(e) => to_epoch_lenient(ts).is_none_or(|secs| secs >= *e),
102        }
103    }
104
105    fn label(&self) -> String {
106        match self {
107            Since::Str(s) => s.to_string(),
108            Since::Epoch(e) => format!("epoch:{e}"),
109        }
110    }
111}
112
113/// Parse an RFC3339-ish timestamp to epoch seconds, tolerating the ledger's
114/// non-strict forms. `rfc3339_like_to_secs` requires exactly `...SSZ` (20 bytes,
115/// trailing Z), but ledger `completed`/`timestamp` are Python
116/// `datetime.now().isoformat()` (`2026-07-02T17:11:14.397919`, no Z, fractional
117/// seconds). Truncating to the second and appending `Z` reuses the same strict
118/// parser, so the `--since-epoch` bound actually filters ledger rows instead of
119/// letting every row through as unparseable.
120fn to_epoch_lenient(ts: &str) -> Option<u64> {
121    crate::state::rfc3339_like_to_secs(ts).or_else(|| {
122        let secs = ts.get(..19)?;
123        crate::state::rfc3339_like_to_secs(&format!("{secs}Z"))
124    })
125}
126
127/// Does this event's session id fall in `set`? Branch A carries
128/// `data.session_id`, Branch B a flat `session_id`.
129fn event_session_in(v: &Value, set: &HashSet<&str>) -> bool {
130    field(v, "session_id")
131        .and_then(|s| s.as_str())
132        .is_some_and(|s| set.contains(s))
133}
134
135/// The pure fold. `events_raw` is the newline-joined concatenation of every
136/// events.jsonl source; `ledger_raw` is ledger.json content. Both are tolerant:
137/// a bad event line bumps `skipped_lines`; an unparseable ledger yields cost 0.
138pub fn fold(events_raw: &str, ledger_raw: &str, selector: &str, since: &str) -> DigestSummary {
139    fold_since(events_raw, ledger_raw, selector, Since::Str(since))
140}
141
142fn fold_since(
143    events_raw: &str,
144    ledger_raw: &str,
145    selector: &str,
146    since: Since<'_>,
147) -> DigestSummary {
148    // Resolve the ledger FIRST: the selector may be a real fno session id, or a
149    // node id / title / worktree basename (what the mux client hands us from the
150    // focused pane's cwd). The ledger maps any of those to the concrete session
151    // id(s), which is how the event fold below finds the loop_check lines.
152    let ledger = resolve_from_ledger(ledger_raw, selector, since);
153    let mut session_ids: HashSet<&str> = ledger.session_ids.iter().map(String::as_str).collect();
154    session_ids.insert(selector);
155
156    let mut skipped_lines = 0usize;
157    let mut commit_shas: Vec<String> = Vec::new(); // ordered, for transition count
158    let mut pr_state: Option<String> = None;
159    let mut ci: Option<String> = None;
160    let mut reviewed = false;
161    let mut blocked_episodes = 0usize;
162    let mut last_block_reason: Option<String> = None;
163    let mut resolved = false;
164    let mut terminated = false;
165    let mut last_decision_blocked = false;
166    // The same event can be mirrored into more than one source (project +
167    // global events.jsonl), so an exact-duplicate matched line is folded once -
168    // else a mirrored block spell would double the episode count and the commit
169    // transitions. Only session-matched lines are held, so this stays small.
170    let mut seen_lines: HashSet<&str> = HashSet::new();
171
172    for line in events_raw.lines() {
173        if line.trim().is_empty() {
174            continue;
175        }
176        let Ok(v) = serde_json::from_str::<Value>(line) else {
177            skipped_lines += 1;
178            continue;
179        };
180        if !event_session_in(&v, &session_ids) {
181            continue;
182        }
183        if !since.includes(event_ts(&v)) {
184            continue;
185        }
186        if !seen_lines.insert(line) {
187            continue; // exact duplicate of an already-folded event
188        }
189        match event_kind(&v) {
190            Some("loop_check") => {
191                if let Some(fp) = field(&v, "fingerprint").and_then(|f| f.as_str()) {
192                    // fingerprint = HEAD_sha|pr_state|ci|latest_review_ts
193                    let head = fp.split('|').next().unwrap_or("");
194                    if !head.is_empty() && head != "none" {
195                        if commit_shas.last().map(String::as_str) != Some(head) {
196                            commit_shas.push(head.to_string());
197                        }
198                    }
199                }
200                if let Some(s) = field(&v, "pr_state").and_then(|s| s.as_str()) {
201                    if s != "none" {
202                        pr_state = Some(s.to_string());
203                    }
204                }
205                if let Some(c) = field(&v, "ci").and_then(|c| c.as_str()) {
206                    if c != "none" {
207                        ci = Some(c.to_string());
208                    }
209                }
210                if let Some(r) = field(&v, "reviewed").and_then(|r| r.as_bool()) {
211                    reviewed = r;
212                }
213                let decision = field(&v, "decision").and_then(|d| d.as_str());
214                if decision == Some("block") {
215                    // Count EPISODES (transitions into blocked), not rows: a
216                    // stop-hook fires loop_check repeatedly through one block
217                    // spell, so only the first block after a non-block decision
218                    // starts a new episode. A fresh episode is unresolved until a
219                    // following allow (reset so `resolved` reflects the LAST
220                    // episode: block -> allow -> block again reads UNRESOLVED).
221                    if !last_decision_blocked {
222                        blocked_episodes += 1;
223                        resolved = false;
224                    }
225                    last_decision_blocked = true;
226                    // Name CI only when it's the actual blocker (a failure or a
227                    // pending run); a green/absent CI means the block was review
228                    // or promise related, so the intent is the informative reason
229                    // (avoids a misleading "blocked on SUCCESS").
230                    let ci_blocking = field(&v, "ci")
231                        .and_then(|c| c.as_str())
232                        .filter(|c| c.starts_with("FAILURE") || *c == "PENDING");
233                    last_block_reason = ci_blocking
234                        .or_else(|| field(&v, "intent").and_then(|i| i.as_str()))
235                        .filter(|r| *r != "none")
236                        .map(str::to_string);
237                } else if decision == Some("allow") {
238                    if last_decision_blocked {
239                        resolved = true;
240                    }
241                    last_decision_blocked = false;
242                }
243            }
244            Some("termination") | Some("session_finalized") | Some("node_closed") => {
245                terminated = true;
246            }
247            _ => {}
248        }
249    }
250
251    // commit count = number of HEAD-sha transitions observed in the window.
252    // First observed sha is the baseline (not a new commit), so subtract it.
253    let commits = commit_shas.len().saturating_sub(1);
254
255    let state = if terminated {
256        "done"
257    } else if last_decision_blocked {
258        "blocked"
259    } else {
260        "working"
261    }
262    .to_string();
263
264    let mut summary = DigestSummary {
265        session: selector.to_string(),
266        since: since.label(),
267        commits,
268        pr_number: ledger.pr_number,
269        pr_url: ledger.pr_url,
270        pr_state,
271        ci,
272        reviewed,
273        blocked_episodes,
274        last_block_reason,
275        resolved,
276        cost_usd: ledger.cost_usd,
277        state,
278        skipped_lines,
279        lines: Vec::new(),
280    };
281    summary.lines = render_lines(&summary);
282    summary
283}
284
285/// What the ledger resolves for a selector.
286#[derive(Default)]
287struct LedgerFold {
288    /// Concrete fno session ids discovered from matched entries (used to find
289    /// the matching events).
290    session_ids: HashSet<String>,
291    pr_number: Option<u64>,
292    pr_url: Option<String>,
293    cost_usd: f64,
294}
295
296/// The basename of a `/`-separated path value (worktree/root_path), lowercased
297/// comparison left to the caller.
298fn basename(path: &str) -> &str {
299    path.rsplit('/').next().unwrap_or(path)
300}
301
302/// Does this ledger entry match `selector`? An entry matches by scalar
303/// `session_id`, membership in its `sessions[]` array (execution rows carry both
304/// a provider UUID and the fno session id there), `graph_node_id`, `title`, or
305/// the basename of `worktree` / `root_path` (so a node id like `x-4e2d`, which
306/// is also the worktree directory name, resolves).
307fn ledger_entry_matches(entry: &Value, selector: &str) -> bool {
308    if entry.get("session_id").and_then(|s| s.as_str()) == Some(selector) {
309        return true;
310    }
311    if entry
312        .get("sessions")
313        .and_then(|s| s.as_array())
314        .is_some_and(|arr| arr.iter().any(|s| s.as_str() == Some(selector)))
315    {
316        return true;
317    }
318    for key in ["graph_node_id", "title"] {
319        if entry.get(key).and_then(|v| v.as_str()) == Some(selector) {
320            return true;
321        }
322    }
323    for key in ["worktree", "root_path"] {
324        if entry
325            .get(key)
326            .and_then(|v| v.as_str())
327            .is_some_and(|p| basename(p) == selector)
328        {
329            return true;
330        }
331    }
332    false
333}
334
335/// Resolve session ids + sum `cost_usd` + pull the latest PR ref from ledger
336/// entries matching `selector`, restricted to `ts >= since`. Tolerant: an
337/// unparseable ledger yields an empty fold.
338fn resolve_from_ledger(ledger_raw: &str, selector: &str, since: Since<'_>) -> LedgerFold {
339    let mut out = LedgerFold::default();
340    let Ok(root) = serde_json::from_str::<Value>(ledger_raw) else {
341        return out;
342    };
343    // Tolerate both {"entries":[...]} and a bare [...].
344    let Some(entries) = root
345        .get("entries")
346        .and_then(|e| e.as_array())
347        .or_else(|| root.as_array())
348    else {
349        return out;
350    };
351
352    for entry in entries {
353        if !ledger_entry_matches(entry, selector) {
354            continue;
355        }
356        // Collect the concrete session id(s) even from a pre-`since` entry, so
357        // the event fold can still find in-window events for a node resolved via
358        // an older ledger row.
359        if let Some(sid) = entry.get("session_id").and_then(|s| s.as_str()) {
360            out.session_ids.insert(sid.to_string());
361        }
362        if let Some(arr) = entry.get("sessions").and_then(|s| s.as_array()) {
363            for s in arr.iter().filter_map(|s| s.as_str()) {
364                out.session_ids.insert(s.to_string());
365            }
366        }
367        // Timestamp key varies across writers: timestamp | completed | ts.
368        let ts = ["timestamp", "completed", "ts"]
369            .iter()
370            .find_map(|k| entry.get(*k).and_then(|v| v.as_str()))
371            .unwrap_or("");
372        if !since.includes(ts) {
373            continue;
374        }
375        if let Some(c) = entry.get("cost_usd").and_then(|c| c.as_f64()) {
376            out.cost_usd += c;
377        }
378        if let Some(n) = entry.get("pr_number").and_then(|n| n.as_u64()) {
379            out.pr_number = Some(n);
380        }
381        if let Some(u) = entry.get("pr_url").and_then(|u| u.as_str()) {
382            out.pr_url = Some(u.to_string());
383        }
384    }
385    out
386}
387
388/// True when nothing worth interrupting an attach for happened in the window:
389/// no commits, no PR, no blocks, no cost, and not terminated. Such a fold
390/// renders zero lines, so the client shows no overlay (a long absence with no
391/// activity is silent, not a "no PR yet / 0 commits" nag).
392fn is_empty_digest(s: &DigestSummary) -> bool {
393    s.commits == 0
394        && s.pr_number.is_none()
395        && s.blocked_episodes == 0
396        && s.cost_usd == 0.0
397        && s.state != "done"
398        && s.state != "blocked"
399}
400
401/// Ranked short lines, attention (blocked) first. An empty digest renders
402/// nothing (the caller suppresses the overlay).
403fn render_lines(s: &DigestSummary) -> Vec<String> {
404    if is_empty_digest(s) {
405        return Vec::new();
406    }
407    let mut lines = Vec::new();
408
409    if s.blocked_episodes > 0 {
410        let reason = s.last_block_reason.as_deref().unwrap_or("gate");
411        let resolution = if s.resolved { "resolved" } else { "UNRESOLVED" };
412        let plural = if s.blocked_episodes == 1 { "" } else { "s" };
413        lines.push(format!(
414            "! {} block{plural} (last: {reason}) - {resolution}",
415            s.blocked_episodes
416        ));
417    }
418
419    match (s.pr_number, s.pr_state.as_deref()) {
420        (Some(n), state) => {
421            let state = state.unwrap_or("open");
422            let ci = s.ci.as_deref().unwrap_or("?");
423            let review = if s.reviewed { "reviewed" } else { "unreviewed" };
424            lines.push(format!("PR #{n} {state} - CI {ci} - {review}"));
425        }
426        (None, _) => lines.push("no PR yet".to_string()),
427    }
428
429    let commit_word = if s.commits == 1 { "commit" } else { "commits" };
430    lines.push(format!(
431        "{} {commit_word} - ${:.2} - state: {}",
432        s.commits, s.cost_usd, s.state
433    ));
434
435    lines
436}
437
438/// Default event/ledger source paths. The Python target/hook events + ledger
439/// live in `~/.fno/` (the PARENT of the agents home `~/.fno/agents`); project
440/// events additionally in `<cwd>/.fno/events.jsonl`.
441fn default_sources(home: &AgentsHome) -> (Vec<PathBuf>, PathBuf) {
442    let fno_dir = home
443        .root()
444        .parent()
445        .map(Path::to_path_buf)
446        .unwrap_or_else(|| PathBuf::from(".fno"));
447    let global_events = fno_dir.join("events.jsonl");
448    let project_events = PathBuf::from(".fno").join("events.jsonl");
449    let ledger = fno_dir.join("ledger.json");
450    (vec![project_events, global_events], ledger)
451}
452
453struct DigestArgs {
454    session: String,
455    since: String,
456    /// `--since-epoch <secs>`: the absence-window bound in epoch seconds (what
457    /// the attach overlay passes). Wins over `--since` when both are given.
458    since_epoch: Option<u64>,
459    json: bool,
460    events_override: Vec<PathBuf>,
461    ledger_override: Option<PathBuf>,
462}
463
464fn parse_args(rest: &[String]) -> Result<DigestArgs, String> {
465    let mut session: Option<String> = None;
466    let mut since = String::new();
467    let mut since_epoch: Option<u64> = None;
468    let mut json = false;
469    let mut events_override: Vec<PathBuf> = Vec::new();
470    let mut ledger_override: Option<PathBuf> = None;
471
472    let mut it = expand_eq(rest).into_iter();
473    while let Some(a) = it.next() {
474        match a.as_str() {
475            "--session" => session = it.next(),
476            "--since" => since = it.next().ok_or("--since needs a value")?,
477            "--since-epoch" => {
478                since_epoch = Some(
479                    it.next()
480                        .and_then(|v| v.parse::<u64>().ok())
481                        .ok_or("--since-epoch needs a non-negative integer")?,
482                )
483            }
484            "--json" | "-J" => json = true,
485            // Hidden test hooks: point the fold at fixture files.
486            "--events" => {
487                events_override.push(PathBuf::from(it.next().ok_or("--events needs a path")?))
488            }
489            "--ledger" => {
490                ledger_override = Some(PathBuf::from(it.next().ok_or("--ledger needs a path")?))
491            }
492            other => return Err(format!("unknown digest flag: {other}")),
493        }
494    }
495    let session = match session {
496        Some(s) if !s.is_empty() => s,
497        _ => return Err("digest needs --session".into()),
498    };
499    Ok(DigestArgs {
500        session,
501        since,
502        since_epoch,
503        json,
504        events_override,
505        ledger_override,
506    })
507}
508
509/// Split `--key=value` into `["--key","value"]`.
510fn expand_eq(rest: &[String]) -> Vec<String> {
511    let mut out = Vec::with_capacity(rest.len());
512    for a in rest {
513        if let Some(eq) = a.find('=') {
514            if a.starts_with("--") && eq > 2 {
515                out.push(a[..eq].to_string());
516                out.push(a[eq + 1..].to_string());
517                continue;
518            }
519        }
520        out.push(a.clone());
521    }
522    out
523}
524
525/// The `fno-agents digest` verb. Read-only; exits 0 on empty/corrupt input
526/// (only a usage error exits 2), so an attach-time caller never sees a failure.
527pub async fn run_digest(rest: &[String], home: &AgentsHome) -> i32 {
528    let args = match parse_args(rest) {
529        Ok(a) => a,
530        Err(msg) => {
531            eprintln!("fno-agents: {msg}");
532            return 2;
533        }
534    };
535
536    let (default_events, default_ledger) = default_sources(home);
537    let event_paths = if args.events_override.is_empty() {
538        default_events
539    } else {
540        args.events_override
541    };
542    let ledger_path = args.ledger_override.unwrap_or(default_ledger);
543
544    let mut events_raw = String::new();
545    for p in &event_paths {
546        if let Ok(content) = std::fs::read_to_string(p) {
547            events_raw.push_str(&content);
548            if !content.ends_with('\n') {
549                events_raw.push('\n');
550            }
551        }
552    }
553    let ledger_raw = std::fs::read_to_string(&ledger_path).unwrap_or_default();
554
555    let since = match args.since_epoch {
556        Some(e) => Since::Epoch(e),
557        None => Since::Str(&args.since),
558    };
559    let summary = fold_since(&events_raw, &ledger_raw, &args.session, since);
560
561    if args.json {
562        println!(
563            "{}",
564            serde_json::to_string(&summary).expect("serializing an owned value never fails")
565        );
566    } else {
567        for line in &summary.lines {
568            println!("{line}");
569        }
570    }
571    0
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    // A loop_check line in the Python (Branch A) envelope.
579    fn loop_check(
580        ts: &str,
581        session: &str,
582        head: &str,
583        pr: &str,
584        ci: &str,
585        reviewed: bool,
586        decision: &str,
587    ) -> String {
588        format!(
589            r#"{{"ts":"{ts}","type":"loop_check","source":"hook","data":{{"session_id":"{session}","fingerprint":"{head}|{pr}|{ci}|none","pr_state":"{pr}","ci":"{ci}","reviewed":{reviewed},"decision":"{decision}","intent":"promise"}}}}"#
590        )
591    }
592
593    #[test]
594    fn happy_names_pr_blocked_and_cost() {
595        // Agent committed twice (base -> c1 -> c2 = 2 transitions), opened PR#42,
596        // blocked once then resolved.
597        let events = [
598            loop_check(
599                "2026-07-03T01:00:00Z",
600                "sess-A",
601                "base",
602                "none",
603                "PENDING",
604                false,
605                "block",
606            ),
607            loop_check(
608                "2026-07-03T02:00:00Z",
609                "sess-A",
610                "c1",
611                "OPEN",
612                "PENDING",
613                false,
614                "block",
615            ),
616            loop_check(
617                "2026-07-03T03:00:00Z",
618                "sess-A",
619                "c2",
620                "OPEN",
621                "SUCCESS",
622                true,
623                "allow",
624            ),
625        ]
626        .join("\n");
627        let ledger = r#"{"entries":[{"session_id":"sess-A","cost_usd":1.5,"pr_number":42,"pr_url":"https://x/pull/42","completed":"2026-07-03T03:00:00Z"}]}"#;
628
629        let d = fold(&events, ledger, "sess-A", "2026-07-03T00:00:00Z");
630        assert_eq!(d.pr_number, Some(42), "PR named");
631        assert_eq!(d.commits, 2, "base->c1->c2 = 2 commits");
632        // Two consecutive block rows are ONE spell (episode), then resolved.
633        assert_eq!(d.blocked_episodes, 1);
634        assert!(d.resolved, "a later allow resolved the block");
635        assert_eq!(d.cost_usd, 1.5);
636        assert!(d.reviewed);
637        assert_eq!(d.skipped_lines, 0);
638        // The rendered lines carry the PR and the block.
639        assert!(
640            d.lines.iter().any(|l| l.contains("#42")),
641            "lines name the PR: {:?}",
642            d.lines
643        );
644        assert!(
645            d.lines.iter().any(|l| l.contains("block")),
646            "lines name the block"
647        );
648    }
649
650    #[test]
651    fn corrupt_line_is_skipped_and_counted() {
652        let events = [
653            loop_check(
654                "2026-07-03T02:00:00Z",
655                "sess-A",
656                "c1",
657                "OPEN",
658                "SUCCESS",
659                true,
660                "allow",
661            ),
662            "{ this is not valid json".to_string(),
663            "".to_string(),
664        ]
665        .join("\n");
666        let d = fold(&events, "", "sess-A", "");
667        assert_eq!(d.skipped_lines, 1, "one bad line, blank line not counted");
668        assert_eq!(d.pr_state.as_deref(), Some("OPEN"));
669    }
670
671    #[test]
672    fn since_in_future_is_empty() {
673        let events = loop_check(
674            "2026-07-03T02:00:00Z",
675            "sess-A",
676            "c1",
677            "OPEN",
678            "SUCCESS",
679            true,
680            "allow",
681        );
682        let d = fold(&events, "", "sess-A", "2099-01-01T00:00:00Z");
683        assert_eq!(d.commits, 0);
684        assert_eq!(d.blocked_episodes, 0);
685        assert!(d.pr_state.is_none());
686        assert_eq!(d.cost_usd, 0.0);
687        // Nothing happened in-window -> an empty digest renders no lines, so the
688        // client shows no overlay (a long, quiet absence is silent).
689        assert!(
690            d.lines.is_empty(),
691            "empty digest renders nothing: {:?}",
692            d.lines
693        );
694    }
695
696    #[test]
697    fn other_session_ignored() {
698        let events = [
699            loop_check(
700                "2026-07-03T02:00:00Z",
701                "sess-A",
702                "c1",
703                "OPEN",
704                "SUCCESS",
705                true,
706                "allow",
707            ),
708            loop_check(
709                "2026-07-03T02:00:00Z",
710                "sess-B",
711                "z9",
712                "MERGED",
713                "SUCCESS",
714                true,
715                "allow",
716            ),
717        ]
718        .join("\n");
719        let d = fold(&events, "", "sess-A", "");
720        assert_eq!(
721            d.pr_state.as_deref(),
722            Some("OPEN"),
723            "sess-B's MERGED must not leak in"
724        );
725    }
726
727    #[test]
728    fn cost_matches_sessions_array_membership() {
729        // Execution rows carry the fno session id in sessions[], not session_id.
730        let ledger = r#"[{"sessions":["uuid-1","sess-A"],"cost_usd":23.45,"pr_number":7,"pr_url":"https://x/pull/7","completed":"2026-07-03T03:00:00Z"}]"#;
731        let l = resolve_from_ledger(ledger, "sess-A", Since::Str(""));
732        assert_eq!(l.pr_number, Some(7));
733        assert_eq!(l.pr_url.as_deref(), Some("https://x/pull/7"));
734        assert_eq!(l.cost_usd, 23.45);
735    }
736
737    #[test]
738    fn ledger_cost_respects_since() {
739        let ledger = r#"[{"session_id":"sess-A","cost_usd":9.0,"timestamp":"2026-07-01T00:00:00Z"},{"session_id":"sess-A","cost_usd":1.0,"timestamp":"2026-07-03T00:00:00Z"}]"#;
740        let l = resolve_from_ledger(ledger, "sess-A", Since::Str("2026-07-02T00:00:00Z"));
741        assert_eq!(
742            l.cost_usd, 1.0,
743            "the pre-since entry is excluded from the delta"
744        );
745    }
746
747    #[test]
748    fn node_id_selector_resolves_via_ledger_to_events() {
749        // The mux client hands a node id (worktree basename), not a session id.
750        // The ledger row maps the node -> its fno session id, which then finds
751        // the loop_check events.
752        let ledger = r#"[{"graph_node_id":"x-4e2d","worktree":"/w/footnote/x-4e2d","session_id":"20260703T-abc","cost_usd":2.0,"pr_number":99,"pr_url":"https://x/pull/99","completed":"2026-07-03T03:00:00Z"}]"#;
753        let events = loop_check(
754            "2026-07-03T02:00:00Z",
755            "20260703T-abc",
756            "c1",
757            "OPEN",
758            "SUCCESS",
759            true,
760            "block",
761        );
762        let d = fold(&events, ledger, "x-4e2d", "");
763        assert_eq!(
764            d.pr_number,
765            Some(99),
766            "PR resolved from the ledger by node id"
767        );
768        assert_eq!(d.cost_usd, 2.0);
769        assert_eq!(
770            d.pr_state.as_deref(),
771            Some("OPEN"),
772            "events found via the resolved session id"
773        );
774        assert_eq!(d.blocked_episodes, 1);
775    }
776
777    #[test]
778    fn mirrored_events_are_folded_once() {
779        // The same block spell mirrored into both event sources (project +
780        // global, concatenated) must not double the episode count.
781        let one = loop_check(
782            "2026-07-03T01:00:00Z",
783            "s",
784            "a",
785            "OPEN",
786            "FAILURE:x",
787            false,
788            "block",
789        );
790        let events = [one.clone(), one].join("\n");
791        let d = fold(&events, "", "s", "");
792        assert_eq!(d.blocked_episodes, 1, "the duplicate line is folded once");
793    }
794
795    #[test]
796    fn repeated_block_rows_are_one_episode() {
797        // A stop-hook fires loop_check repeatedly through one block spell.
798        let events = [
799            loop_check(
800                "2026-07-03T01:00:00Z",
801                "s",
802                "a",
803                "OPEN",
804                "FAILURE:x",
805                false,
806                "block",
807            ),
808            loop_check(
809                "2026-07-03T01:05:00Z",
810                "s",
811                "a",
812                "OPEN",
813                "FAILURE:x",
814                false,
815                "block",
816            ),
817            loop_check(
818                "2026-07-03T01:10:00Z",
819                "s",
820                "a",
821                "OPEN",
822                "FAILURE:x",
823                false,
824                "block",
825            ),
826        ]
827        .join("\n");
828        let d = fold(&events, "", "s", "");
829        assert_eq!(
830            d.blocked_episodes, 1,
831            "three block rows, one unresolved spell"
832        );
833    }
834
835    #[test]
836    fn ledger_epoch_filter_parses_non_z_microsecond_ts() {
837        // Ledger `completed` is Python isoformat (no Z, fractional). The epoch
838        // bound must still parse it, or the since-window would leak old rows.
839        let ledger =
840            r#"[{"session_id":"s","cost_usd":5.0,"completed":"2026-07-01T00:00:00.123456"}]"#;
841        let after = crate::state::rfc3339_like_to_secs("2026-07-02T00:00:00Z").unwrap();
842        let l = resolve_from_ledger(ledger, "s", Since::Epoch(after));
843        assert_eq!(
844            l.cost_usd, 0.0,
845            "the pre-window microsecond-ts row is excluded"
846        );
847    }
848
849    #[test]
850    fn reblock_after_resolve_reads_unresolved() {
851        // block -> allow (resolved) -> block again, still blocked at the end.
852        // `resolved` must reflect the LAST episode (unresolved), not the earlier
853        // resolution (gemini high-priority finding).
854        let events = [
855            loop_check(
856                "2026-07-03T01:00:00Z",
857                "s",
858                "a",
859                "OPEN",
860                "FAILURE:x",
861                false,
862                "block",
863            ),
864            loop_check(
865                "2026-07-03T02:00:00Z",
866                "s",
867                "b",
868                "OPEN",
869                "SUCCESS",
870                true,
871                "allow",
872            ),
873            loop_check(
874                "2026-07-03T03:00:00Z",
875                "s",
876                "c",
877                "OPEN",
878                "FAILURE:y",
879                false,
880                "block",
881            ),
882        ]
883        .join("\n");
884        let d = fold(&events, "", "s", "");
885        assert_eq!(d.blocked_episodes, 2);
886        assert!(!d.resolved, "the current (last) block is unresolved");
887        assert_eq!(d.state, "blocked");
888        assert!(d.lines.iter().any(|l| l.contains("UNRESOLVED")));
889    }
890
891    #[test]
892    fn since_epoch_scopes_to_the_absence_window() {
893        // Two loop_checks: one before the "detach", one after. An epoch bound at
894        // the detach time must exclude the earlier one.
895        let events = [
896            loop_check(
897                "2026-07-03T01:00:00Z",
898                "sess-A",
899                "old",
900                "OPEN",
901                "PENDING",
902                false,
903                "block",
904            ),
905            loop_check(
906                "2026-07-03T05:00:00Z",
907                "sess-A",
908                "new",
909                "OPEN",
910                "SUCCESS",
911                true,
912                "allow",
913            ),
914        ]
915        .join("\n");
916        // 2026-07-03T03:00:00Z == 1751511600 epoch.
917        let detach = crate::state::rfc3339_like_to_secs("2026-07-03T03:00:00Z").unwrap();
918        let d = fold_since(&events, "", "sess-A", Since::Epoch(detach));
919        assert_eq!(
920            d.blocked_episodes, 0,
921            "the pre-detach block is out of window"
922        );
923        assert!(d.reviewed, "the in-window allow still counts");
924        assert!(d.since.starts_with("epoch:"));
925    }
926
927    #[test]
928    fn worktree_basename_selector_matches() {
929        let entry: Value =
930            serde_json::from_str(r#"{"worktree":"/Users/x/conductor/workspaces/footnote/x-4e2d"}"#)
931                .unwrap();
932        assert!(ledger_entry_matches(&entry, "x-4e2d"));
933        assert!(!ledger_entry_matches(&entry, "footnote"));
934    }
935}