Skip to main content

aft/
alert_render.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4/// The rendered reminder is intentionally short enough to preserve the response it annotates.
5/// Gate 6 will replace this product constant with its ruled value.
6pub const MAX_ALERT_LINE_CHARS: usize = 240;
7pub const ALERT_ELLIPSIS: &str = "…";
8pub const MAX_RENDERED_ALERT_LINES: usize = 3;
9
10/// Commands in this closed list are transport or maintenance traffic, not agent-visible tool
11/// responses. Keep additions explicit so a new command cannot silently consume a pending alert.
12pub const EXCLUDED_FINALIZATION_COMMANDS: &[&str] = &[
13    "configure",
14    "ping",
15    "version",
16    "status",
17    "bash_abort_inflight",
18    "bash_status",
19    "bash_write",
20    "bash_promote",
21    "bash_wait_detach",
22    "bash_regex_match",
23    "bash_drain_completions",
24    "bash_notify",
25    "bash_unnotify",
26    "bash_ack_completions",
27];
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AlertSeverity {
31    Error,
32    Warning,
33    Information,
34    Hint,
35}
36
37impl AlertSeverity {
38    fn canonical_name(self) -> &'static str {
39        match self {
40            Self::Error => "error",
41            Self::Warning => "warning",
42            Self::Information => "information",
43            Self::Hint => "hint",
44        }
45    }
46}
47
48/// A diagnostic accepted from one document-version-verified producer snapshot.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AlertDiagnostic {
51    /// Canonical, dispatch-root-relative path supplied by the observation producer.
52    pub file: String,
53    pub line: u32,
54    pub column: u32,
55    pub end_line: u32,
56    pub end_column: u32,
57    pub severity: AlertSeverity,
58    pub source: Option<String>,
59    pub code: Option<String>,
60    pub message: String,
61}
62
63impl AlertDiagnostic {
64    #[must_use]
65    pub fn error(file: impl Into<String>, line: u32, message: impl Into<String>) -> Self {
66        Self {
67            file: file.into(),
68            line,
69            column: 0,
70            end_line: line,
71            end_column: 0,
72            severity: AlertSeverity::Error,
73            source: None,
74            code: None,
75            message: message.into(),
76        }
77    }
78
79    fn identity(&self) -> AlertIdentity {
80        AlertIdentity {
81            file: self.file.clone(),
82            line: self.line,
83            column: self.column,
84            end_line: self.end_line,
85            end_column: self.end_column,
86            severity: self.severity.canonical_name().to_string(),
87            source: self.source.clone().unwrap_or_default(),
88            code: self.code.clone().unwrap_or_default(),
89            message: normalize_alert_message(&self.message),
90        }
91    }
92}
93
94/// One complete, accepted snapshot from a single diagnostics producer.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct AlertObservation {
97    pub producer_key: String,
98    pub diagnostics: Vec<AlertDiagnostic>,
99}
100
101impl AlertObservation {
102    #[must_use]
103    pub fn new(producer_key: impl Into<String>, diagnostics: Vec<AlertDiagnostic>) -> Self {
104        Self {
105            producer_key: producer_key.into(),
106            diagnostics,
107        }
108    }
109}
110
111/// Canonical identity used for de-duplication, ordering ties, and rendered tracking.
112#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
113pub struct AlertIdentity {
114    pub file: String,
115    pub line: u32,
116    pub column: u32,
117    pub end_line: u32,
118    pub end_column: u32,
119    pub severity: String,
120    pub source: String,
121    pub code: String,
122    pub message: String,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126struct AlertCandidate {
127    identity: AlertIdentity,
128    entered_observation_ordinal: u64,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
132struct PartitionKey {
133    dispatch_root: PathBuf,
134    producer_key: String,
135}
136
137#[derive(Debug, Default)]
138struct ProducerPartition {
139    baseline_established: bool,
140    live: BTreeMap<AlertIdentity, AlertCandidate>,
141    rendered: BTreeSet<AlertIdentity>,
142}
143
144#[derive(Debug, Default)]
145struct SessionAlertState {
146    next_observation_ordinal: u64,
147    agent_visible_response_ordinal: u64,
148    partitions: BTreeMap<PartitionKey, ProducerPartition>,
149}
150
151/// Session-owned alert delta state. A host may retain one engine for its session registry.
152///
153/// The engine deliberately accepts a dispatch root at both observation and finalization. It
154/// never consults an `AppContext` or a session project root, because a response may be scoped to
155/// a root other than the context that happens to dispatch it.
156#[derive(Debug, Default)]
157pub struct AlertEngine {
158    sessions: BTreeMap<String, SessionAlertState>,
159}
160
161/// The server-rendered result of one finalized block.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct RenderedAlert {
164    pub text: String,
165    pub shown: Vec<AlertIdentity>,
166    pub counted_only: Vec<AlertIdentity>,
167    pub agent_visible_response_ordinal: u64,
168}
169
170impl RenderedAlert {
171    #[must_use]
172    pub fn represented_identities(&self) -> impl Iterator<Item = &AlertIdentity> {
173        self.shown.iter().chain(&self.counted_only)
174    }
175}
176
177impl AlertEngine {
178    /// Apply one atomic authoritative-observation batch. Empty snapshots are meaningful: they
179    /// prune only their producer partition and never affect another producer or root.
180    pub fn observe_authoritative_batch(
181        &mut self,
182        session_id: &str,
183        dispatch_root: &Path,
184        observations: impl IntoIterator<Item = AlertObservation>,
185    ) {
186        let root = canonical_dispatch_root(dispatch_root);
187        let state = self.sessions.entry(session_id.to_string()).or_default();
188        state.next_observation_ordinal = state.next_observation_ordinal.saturating_add(1);
189        let observation_ordinal = state.next_observation_ordinal;
190
191        for observation in observations {
192            let key = PartitionKey {
193                dispatch_root: root.clone(),
194                producer_key: observation.producer_key,
195            };
196            let current = observation
197                .diagnostics
198                .into_iter()
199                .filter(|diagnostic| diagnostic.severity == AlertSeverity::Error)
200                .map(|diagnostic| diagnostic.identity())
201                .collect::<BTreeSet<_>>();
202            let partition = state.partitions.entry(key).or_default();
203
204            if !partition.baseline_established {
205                // Gate 10 has not supplied the session-owned mutation store required to
206                // attribute a first observation, so each partition establishes a silent baseline.
207                partition.baseline_established = true;
208                partition.live = current
209                    .into_iter()
210                    .map(|identity| {
211                        let candidate = AlertCandidate {
212                            identity: identity.clone(),
213                            entered_observation_ordinal: observation_ordinal,
214                        };
215                        (identity, candidate)
216                    })
217                    .collect();
218                partition.rendered = partition.live.keys().cloned().collect();
219                continue;
220            }
221
222            // A disappearance closes its alert episode. Removing the rendered marker makes a
223            // later re-entry a new candidate rather than a permanently suppressed identity.
224            partition
225                .rendered
226                .retain(|identity| current.contains(identity));
227            let previous_live = std::mem::take(&mut partition.live);
228            partition.live = current
229                .into_iter()
230                .map(|identity| {
231                    let candidate =
232                        previous_live
233                            .get(&identity)
234                            .cloned()
235                            .unwrap_or_else(|| AlertCandidate {
236                                identity: identity.clone(),
237                                entered_observation_ordinal: observation_ordinal,
238                            });
239                    (identity, candidate)
240                })
241                .collect();
242        }
243    }
244
245    /// Apply one complete snapshot for one producer.
246    pub fn observe_authoritative(
247        &mut self,
248        session_id: &str,
249        dispatch_root: &Path,
250        producer_key: impl Into<String>,
251        diagnostics: Vec<AlertDiagnostic>,
252    ) {
253        self.observe_authoritative_batch(
254            session_id,
255            dispatch_root,
256            [AlertObservation::new(producer_key, diagnostics)],
257        );
258    }
259
260    /// Finalize one agent-visible response. Pending candidates are recomputed from the explicit
261    /// dispatch root's `live − rendered` partitions; there is no queued cross-root work list.
262    pub fn finalize(
263        &mut self,
264        session_id: &str,
265        dispatch_root: &Path,
266        command: &str,
267    ) -> Option<RenderedAlert> {
268        if is_excluded_finalization_command(command) {
269            return None;
270        }
271
272        let root = canonical_dispatch_root(dispatch_root);
273        let state = self.sessions.entry(session_id.to_string()).or_default();
274        state.agent_visible_response_ordinal =
275            state.agent_visible_response_ordinal.saturating_add(1);
276        let response_ordinal = state.agent_visible_response_ordinal;
277
278        let mut deliverable = Vec::new();
279        for (partition_key, partition) in &state.partitions {
280            if partition_key.dispatch_root != root {
281                continue;
282            }
283            for (identity, candidate) in &partition.live {
284                if !partition.rendered.contains(identity) {
285                    deliverable.push((partition_key.clone(), candidate.clone()));
286                }
287            }
288        }
289        if deliverable.is_empty() {
290            return None;
291        }
292
293        // Newer observations win. The identity's canonical tuple makes same-observation ordering
294        // deterministic across producers and transports.
295        deliverable.sort_by(|(_, left), (_, right)| {
296            right
297                .entered_observation_ordinal
298                .cmp(&left.entered_observation_ordinal)
299                .then_with(|| left.identity.cmp(&right.identity))
300        });
301
302        let shown_count = if deliverable.len() > MAX_RENDERED_ALERT_LINES - 1 {
303            MAX_RENDERED_ALERT_LINES - 1
304        } else {
305            deliverable.len()
306        };
307        let shown = deliverable
308            .iter()
309            .take(shown_count)
310            .map(|(_, candidate)| candidate.identity.clone())
311            .collect::<Vec<_>>();
312        let counted_only = deliverable
313            .iter()
314            .skip(shown_count)
315            .map(|(_, candidate)| candidate.identity.clone())
316            .collect::<Vec<_>>();
317
318        let mut lines = shown.iter().map(render_alert_line).collect::<Vec<_>>();
319        if !counted_only.is_empty() {
320            lines.push(format!("(+{} more)", counted_only.len()));
321        }
322        let text = format!(
323            "<system-reminder>\n{}\n</system-reminder>",
324            lines.join("\n")
325        );
326
327        // Every represented identity is consumed together, including identities represented only
328        // by the count suffix. This is what makes unchanged responses silent after coalescing.
329        for (partition_key, candidate) in deliverable {
330            if let Some(partition) = state.partitions.get_mut(&partition_key) {
331                partition.rendered.insert(candidate.identity);
332            }
333        }
334
335        Some(RenderedAlert {
336            text,
337            shown,
338            counted_only,
339            agent_visible_response_ordinal: response_ordinal,
340        })
341    }
342
343    #[must_use]
344    pub fn agent_visible_response_ordinal(&self, session_id: &str) -> u64 {
345        self.sessions
346            .get(session_id)
347            .map_or(0, |state| state.agent_visible_response_ordinal)
348    }
349
350    #[must_use]
351    pub fn partition_is_baselined(
352        &self,
353        session_id: &str,
354        dispatch_root: &Path,
355        producer_key: &str,
356    ) -> bool {
357        let key = PartitionKey {
358            dispatch_root: canonical_dispatch_root(dispatch_root),
359            producer_key: producer_key.to_string(),
360        };
361        self.sessions
362            .get(session_id)
363            .and_then(|state| state.partitions.get(&key))
364            .is_some_and(|partition| partition.baseline_established)
365    }
366
367    #[must_use]
368    pub fn partition_rendered_identities(
369        &self,
370        session_id: &str,
371        dispatch_root: &Path,
372        producer_key: &str,
373    ) -> BTreeSet<AlertIdentity> {
374        let key = PartitionKey {
375            dispatch_root: canonical_dispatch_root(dispatch_root),
376            producer_key: producer_key.to_string(),
377        };
378        self.sessions
379            .get(session_id)
380            .and_then(|state| state.partitions.get(&key))
381            .map_or_else(BTreeSet::new, |partition| partition.rendered.clone())
382    }
383}
384
385#[must_use]
386pub fn is_excluded_finalization_command(command: &str) -> bool {
387    EXCLUDED_FINALIZATION_COMMANDS.contains(&command)
388}
389
390/// The sole alert-message normalizer: first line, trim, whitespace-run collapse, and NFC-style
391/// composition. It deliberately does not rewrite paths, numbers, or quotation marks.
392#[must_use]
393pub fn normalize_alert_message(message: &str) -> String {
394    let first_line = message.lines().next().unwrap_or_default().trim();
395    let collapsed = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
396    compose_common_nfc(&collapsed)
397}
398
399fn canonical_dispatch_root(root: &Path) -> PathBuf {
400    std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
401}
402
403fn render_alert_line(identity: &AlertIdentity) -> String {
404    let line = format!(
405        "New error in {}:{}: {}",
406        identity.file, identity.line, identity.message
407    );
408    truncate_alert_line(&line)
409}
410
411fn truncate_alert_line(line: &str) -> String {
412    if line.chars().count() <= MAX_ALERT_LINE_CHARS {
413        return line.to_string();
414    }
415
416    let prefix_len = MAX_ALERT_LINE_CHARS.saturating_sub(ALERT_ELLIPSIS.chars().count());
417    let mut truncated = line.chars().take(prefix_len).collect::<String>();
418    truncated.push_str(ALERT_ELLIPSIS);
419    truncated
420}
421
422/// Compose the canonical decompositions commonly emitted by diagnostics. Rust's standard library
423/// exposes no full Unicode normalization facility, so unfamiliar decompositions are preserved
424/// rather than applying an unsafe text rewrite outside the named normalization contract.
425fn compose_common_nfc(input: &str) -> String {
426    let mut normalized = String::with_capacity(input.len());
427    let mut chars = input.chars().peekable();
428    while let Some(base) = chars.next() {
429        let Some(&mark) = chars.peek() else {
430            normalized.push(base);
431            continue;
432        };
433        if let Some(composed) = compose_pair(base, mark) {
434            normalized.push(composed);
435            chars.next();
436        } else {
437            normalized.push(base);
438        }
439    }
440    normalized
441}
442
443fn compose_pair(base: char, mark: char) -> Option<char> {
444    let composed = match (base, mark) {
445        ('A', '\u{0300}') => 'À',
446        ('A', '\u{0301}') => 'Á',
447        ('A', '\u{0302}') => 'Â',
448        ('A', '\u{0303}') => 'Ã',
449        ('A', '\u{0308}') => 'Ä',
450        ('A', '\u{030A}') => 'Å',
451        ('C', '\u{0327}') => 'Ç',
452        ('E', '\u{0300}') => 'È',
453        ('E', '\u{0301}') => 'É',
454        ('E', '\u{0302}') => 'Ê',
455        ('E', '\u{0308}') => 'Ë',
456        ('I', '\u{0300}') => 'Ì',
457        ('I', '\u{0301}') => 'Í',
458        ('I', '\u{0302}') => 'Î',
459        ('I', '\u{0308}') => 'Ï',
460        ('N', '\u{0303}') => 'Ñ',
461        ('O', '\u{0300}') => 'Ò',
462        ('O', '\u{0301}') => 'Ó',
463        ('O', '\u{0302}') => 'Ô',
464        ('O', '\u{0303}') => 'Õ',
465        ('O', '\u{0308}') => 'Ö',
466        ('U', '\u{0300}') => 'Ù',
467        ('U', '\u{0301}') => 'Ú',
468        ('U', '\u{0302}') => 'Û',
469        ('U', '\u{0308}') => 'Ü',
470        ('Y', '\u{0301}') => 'Ý',
471        ('a', '\u{0300}') => 'à',
472        ('a', '\u{0301}') => 'á',
473        ('a', '\u{0302}') => 'â',
474        ('a', '\u{0303}') => 'ã',
475        ('a', '\u{0308}') => 'ä',
476        ('a', '\u{030A}') => 'å',
477        ('c', '\u{0327}') => 'ç',
478        ('e', '\u{0300}') => 'è',
479        ('e', '\u{0301}') => 'é',
480        ('e', '\u{0302}') => 'ê',
481        ('e', '\u{0308}') => 'ë',
482        ('i', '\u{0300}') => 'ì',
483        ('i', '\u{0301}') => 'í',
484        ('i', '\u{0302}') => 'î',
485        ('i', '\u{0308}') => 'ï',
486        ('n', '\u{0303}') => 'ñ',
487        ('o', '\u{0300}') => 'ò',
488        ('o', '\u{0301}') => 'ó',
489        ('o', '\u{0302}') => 'ô',
490        ('o', '\u{0303}') => 'õ',
491        ('o', '\u{0308}') => 'ö',
492        ('u', '\u{0300}') => 'ù',
493        ('u', '\u{0301}') => 'ú',
494        ('u', '\u{0302}') => 'û',
495        ('u', '\u{0308}') => 'ü',
496        ('y', '\u{0301}') => 'ý',
497        ('y', '\u{0308}') => 'ÿ',
498        _ => return None,
499    };
500    Some(composed)
501}
502
503#[cfg(test)]
504mod tests {
505    use super::{
506        normalize_alert_message, AlertDiagnostic, AlertEngine, AlertSeverity,
507        EXCLUDED_FINALIZATION_COMMANDS, MAX_ALERT_LINE_CHARS,
508    };
509    use std::path::Path;
510
511    fn error(file: &str, line: u32, message: &str) -> AlertDiagnostic {
512        AlertDiagnostic::error(file, line, message)
513    }
514
515    #[test]
516    fn normalizer_uses_only_the_contractual_transformations() {
517        assert_eq!(
518            normalize_alert_message("  Cafe\u{301}   failed\nsecond line"),
519            "Café failed"
520        );
521        assert_eq!(
522            normalize_alert_message("src/a.rs:42 \"quoted\""),
523            "src/a.rs:42 \"quoted\""
524        );
525    }
526
527    #[test]
528    fn first_observation_is_the_named_default_silent_baseline() {
529        let root = Path::new("/dispatch-root");
530        let mut engine = AlertEngine::default();
531        engine.observe_authoritative("session", root, "server-a", vec![error("a.rs", 3, "old")]);
532
533        assert!(engine.partition_is_baselined("session", root, "server-a"));
534        assert!(engine.finalize("session", root, "read").is_none());
535        assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
536
537        engine.observe_authoritative(
538            "session",
539            root,
540            "server-a",
541            vec![error("a.rs", 3, "old"), error("a.rs", 7, "new")],
542        );
543        let alert = engine.finalize("session", root, "read").expect("new alert");
544        assert!(alert.text.contains("a.rs:7: new"));
545        assert!(!alert.text.contains("your edit"));
546        assert_eq!(alert.agent_visible_response_ordinal, 2);
547    }
548
549    #[test]
550    fn cold_root_silence_still_advances_the_session_response_ordinal() {
551        let mut engine = AlertEngine::default();
552        assert!(engine
553            .finalize("session", Path::new("/cold-root"), "read")
554            .is_none());
555        assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
556    }
557
558    #[test]
559    fn dispatch_root_isolation_never_borrows_or_consumes_another_root() {
560        let dispatch_root = Path::new("/dispatch-root");
561        let other_root = Path::new("/other-root");
562        let mut engine = AlertEngine::default();
563        for root in [dispatch_root, other_root] {
564            engine.observe_authoritative("session", root, "server", Vec::new());
565            engine.observe_authoritative(
566                "session",
567                root,
568                "server",
569                vec![error("src/lib.rs", 4, root.to_string_lossy().as_ref())],
570            );
571        }
572
573        let alert = engine
574            .finalize("session", dispatch_root, "inspect")
575            .expect("dispatch-root alert");
576        assert!(alert.text.contains("/dispatch-root"));
577        assert!(!alert.text.contains("/other-root"));
578        assert!(engine
579            .finalize("session", other_root, "read")
580            .expect("other root remains pending")
581            .text
582            .contains("/other-root"));
583    }
584
585    #[test]
586    fn coalescing_orders_ties_and_marks_counted_identities_rendered() {
587        let root = Path::new("/root");
588        let mut engine = AlertEngine::default();
589        engine.observe_authoritative("session", root, "server", Vec::new());
590        engine.observe_authoritative(
591            "session",
592            root,
593            "server",
594            vec![
595                error("z.rs", 3, "z"),
596                error("a.rs", 2, "a"),
597                error("m.rs", 1, "m"),
598            ],
599        );
600
601        let alert = engine
602            .finalize("session", root, "read")
603            .expect("coalesced alert");
604        assert_eq!(alert.text.lines().count(), 5);
605        assert!(alert.text.find("a.rs:2").unwrap() < alert.text.find("m.rs:1").unwrap());
606        assert!(alert.text.contains("(+1 more)"));
607        assert_eq!(alert.represented_identities().count(), 3);
608        assert_eq!(
609            engine
610                .partition_rendered_identities("session", root, "server")
611                .len(),
612            3
613        );
614        assert!(engine.finalize("session", root, "read").is_none());
615    }
616
617    #[test]
618    fn excluded_commands_preserve_pending_alerts_and_do_not_advance_visible_ordinal() {
619        let root = Path::new("/root");
620        let mut engine = AlertEngine::default();
621        engine.observe_authoritative("session", root, "server", Vec::new());
622        engine.observe_authoritative("session", root, "server", vec![error("a.rs", 1, "boom")]);
623
624        for command in EXCLUDED_FINALIZATION_COMMANDS {
625            assert!(engine.finalize("session", root, command).is_none());
626        }
627        assert_eq!(engine.agent_visible_response_ordinal("session"), 0);
628        assert!(engine.finalize("session", root, "inspect").is_some());
629        assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
630    }
631
632    #[test]
633    fn warning_information_and_hints_never_enter_the_alert_channel() {
634        let root = Path::new("/root");
635        let mut engine = AlertEngine::default();
636        for severity in [
637            AlertSeverity::Warning,
638            AlertSeverity::Information,
639            AlertSeverity::Hint,
640        ] {
641            let mut diagnostic = error("a.rs", 1, "not an error");
642            diagnostic.severity = severity;
643            engine.observe_authoritative(
644                "session",
645                root,
646                severity.canonical_name(),
647                vec![diagnostic],
648            );
649        }
650        assert!(engine.finalize("session", root, "read").is_none());
651    }
652
653    #[test]
654    fn truncation_never_wraps_the_rendered_line() {
655        let root = Path::new("/root");
656        let mut engine = AlertEngine::default();
657        engine.observe_authoritative("session", root, "server", Vec::new());
658        engine.observe_authoritative(
659            "session",
660            root,
661            "server",
662            vec![error("a.rs", 1, &"x".repeat(MAX_ALERT_LINE_CHARS * 2))],
663        );
664        let alert = engine
665            .finalize("session", root, "read")
666            .expect("long alert");
667        let line = alert.text.lines().nth(1).expect("alert line");
668        assert!(line.chars().count() <= MAX_ALERT_LINE_CHARS);
669        assert!(!line.contains('\n'));
670    }
671}