Skip to main content

ao_core/
reactions.rs

1//! Reaction engine types — Slice 2 Phase A (data only).
2//!
3//! This module defines the configuration shape the reaction engine will
4//! consume. The engine itself (`ReactionEngine`, `ReactionTracker`, dispatch
5//! logic) lands in Phase D; keeping Phase A to pure data types means the
6//! types can be stabilized and reviewed before we wire them into
7//! `LifecycleManager`.
8//!
9//! Mirrors `ReactionConfig`, `ReactionResult`, and `EventPriority` from
10//! `packages/core/src/types.ts` (lines ~900–995 in the reference).
11//!
12//! ## Design choices worth calling out
13//!
14//! - **Kebab-case `action` and `priority`.** TS uses `"send-to-agent"`,
15//!   `"auto-merge"`, `"urgent"`, `"warning"` as string literals. We match
16//!   them in YAML so a user can drop a TS reaction config into our config
17//!   file unmodified. Session status yaml still uses snake_case because
18//!   that's a different file owned by a different subsystem.
19//!
20//! - **`EscalateAfter` is an untagged enum.** TS's `number | string` union
21//!   becomes `Attempts(u32) | Duration(String)` with `#[serde(untagged)]`,
22//!   so YAML can write either `escalate-after: 3` or `escalate-after: 10m`
23//!   with no wrapper key.
24//!
25//! - **Durations stay as `String` in Phase A.** We don't parse `"10m"` →
26//!   `Duration` here because the parser belongs next to the code that *uses*
27//!   the duration (the engine, Phase D). Leaving them as strings keeps Phase
28//!   A deserialization trivial and defers the "what units do we accept"
29//!   question to when we have a concrete use site.
30
31use crate::scm::MergeMethod;
32use serde::{Deserialize, Serialize};
33
34/// What a reaction should actually do when it fires. Matches the TS
35/// union `"send-to-agent" | "notify" | "auto-merge"` — kebab-case on the
36/// wire so TS config files round-trip unchanged.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum ReactionAction {
40    /// Send a message to the agent, asking it to fix whatever broke.
41    /// Uses `ReactionConfig::message` as the payload.
42    SendToAgent,
43    /// Fire a notification at a human (stdout, Slack, desktop, …).
44    Notify,
45    /// Merge the PR. Only makes sense for `approved-and-green`.
46    AutoMerge,
47}
48
49impl ReactionAction {
50    /// Kebab-case label matching the YAML wire form — used by CLI
51    /// output (`ao-rs watch`) so log rows stay consistent with config
52    /// file keys. Derived `Debug` would give PascalCase, which reads
53    /// weirdly next to `ci_failed`/`status_changed` in the same row.
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            Self::SendToAgent => "send-to-agent",
57            Self::Notify => "notify",
58            Self::AutoMerge => "auto-merge",
59        }
60    }
61}
62
63impl std::fmt::Display for ReactionAction {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(self.as_str())
66    }
67}
68
69/// Notification priority. Matches TS's four-value union verbatim so a
70/// TS `notificationRouting` table could be ported later without a rename.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum EventPriority {
74    /// "Wake someone up." Paged/SMS-class.
75    Urgent,
76    /// "Needs human action soon." Default for send-to-agent failures.
77    Action,
78    /// "Something's off, check when you can." Fallback for unknown reaction keys.
79    Warning,
80    /// "FYI." Default for `approved-and-green` notifications.
81    Info,
82}
83
84impl EventPriority {
85    /// Snake-case label matching the YAML wire form — used by the
86    /// notifier registry (Slice 3 Phase A) for tracing fields and
87    /// warn-once dedup keys so log rows stay consistent with config
88    /// file keys. Mirror of `ReactionAction::as_str` a few lines up;
89    /// derived `Debug` would give PascalCase, which reads weirdly
90    /// next to `ci_failed` / `status_changed` in the same row.
91    pub const fn as_str(self) -> &'static str {
92        match self {
93            Self::Urgent => "urgent",
94            Self::Action => "action",
95            Self::Warning => "warning",
96            Self::Info => "info",
97        }
98    }
99}
100
101/// Default notification priority when `reactions.<key>.priority` is omitted.
102///
103/// [`ReactionEngine`](crate::reaction_engine::ReactionEngine) resolves
104/// `cfg.priority.unwrap_or_else(|| default_priority_for_reaction_key(key))`
105/// so YAML stays minimal and one table defines behavior for all keys.
106pub fn default_priority_for_reaction_key(reaction_key: &str) -> EventPriority {
107    match reaction_key {
108        // Mirrors ao-ts `packages/core/src/lifecycle-manager.ts`:
109        // - `executeReaction` uses `priority ?? "info"` for generic `notify`,
110        //   but some event emitters (e.g. CI and conflicts) default to warning.
111        // This table matches the practical defaults used for each reaction key.
112        "ci-failed" | "merge-conflicts" => EventPriority::Warning,
113        "changes-requested" => EventPriority::Info,
114        "approved-and-green" => EventPriority::Action,
115        "agent-idle" | "all-complete" => EventPriority::Info,
116        "agent-stuck" | "agent-needs-input" | "agent-exited" => EventPriority::Urgent,
117        _ => EventPriority::Warning,
118    }
119}
120
121/// How long/how many attempts before a reaction escalates from
122/// `SendToAgent` → `Notify`. Untagged so YAML can use a bare number *or*
123/// a bare duration string:
124///
125/// ```yaml
126/// ci-failed:
127///   escalate-after: 3       # after 3 failed send attempts
128/// agent-stuck:
129///   escalate-after: 10m     # after 10 minutes of no progress
130/// ```
131///
132/// Serde resolves the variants in order at parse time — `Attempts` is
133/// listed first, so a bare YAML number always goes there. Anything else
134/// falls through to `Duration`.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(untagged)]
137pub enum EscalateAfter {
138    /// Retry `send-to-agent` this many times, then escalate to `notify`.
139    Attempts(u32),
140    /// Wait this long after the first attempt before escalating. String
141    /// form matching the TS regex `^\d+(s|m|h)$` — e.g. `"30s"`,
142    /// `"10m"`, `"2h"`. Compound or fractional forms (`"1h30m"`,
143    /// `"1.5m"`) are rejected. Parsed lazily by `parse_duration` on
144    /// each dispatch so a misconfigured value only logs once and does
145    /// not poison the engine.
146    Duration(String),
147}
148
149/// A single reaction rule, typically read from `~/.ao-rs/config.yaml`
150/// under `reactions.<key>`. See `docs/reactions.md` for the full list of
151/// reaction keys and the matrix of which actions make sense for each.
152///
153/// All fields except `action` have sensible defaults, so the minimal
154/// valid config is one line:
155///
156/// ```yaml
157/// approved-and-green:
158///   action: notify
159/// ```
160///
161/// Everything else — retries, escalation, priority — falls back to a
162/// value the engine considers "reasonable for hobby use".
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct ReactionConfig {
165    /// Master on/off. `false` means the engine sees the reaction key but
166    /// does nothing; useful for disabling individual rules without
167    /// deleting them. Defaults to `true` so newly-added rules are live.
168    ///
169    /// We skip serializing when `true` so a round-tripped config stays
170    /// terse: the common case (enabled) doesn't clutter the output. Pair
171    /// with `include_summary` below — both default-valued fields omit on
172    /// write so a user who hand-edited a minimal config reads back a
173    /// minimal config.
174    #[serde(default = "default_auto", skip_serializing_if = "is_true")]
175    pub auto: bool,
176
177    /// What to do when the reaction fires. No default — you have to pick.
178    pub action: ReactionAction,
179
180    /// Body for `SendToAgent`, override text for `Notify`. Ignored by
181    /// `AutoMerge`. Missing for `SendToAgent` falls back to an
182    /// engine-supplied boilerplate (Phase D).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub message: Option<String>,
185
186    /// Priority for the resulting notification. Defaults to the
187    /// reaction-key-specific default the engine picks (see
188    /// `docs/reactions.md`).
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub priority: Option<EventPriority>,
191
192    /// Max attempts of `SendToAgent` before escalating to `Notify`.
193    /// `None` means "retry forever", matching TS.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub retries: Option<u32>,
196
197    /// Escalate after N attempts or after a wall-clock duration,
198    /// whichever the user configured. Absent means "use `retries` as
199    /// the only gate".
200    #[serde(
201        default,
202        rename = "escalate_after",
203        alias = "escalate-after",
204        skip_serializing_if = "Option::is_none"
205    )]
206    pub escalate_after: Option<EscalateAfter>,
207
208    /// Duration threshold for time-based triggers (e.g. `"10m"` for
209    /// `agent-stuck`). Kept as an opaque string until Phase D adds a
210    /// parser.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub threshold: Option<String>,
213
214    /// Whether to attach a context summary to the notification.
215    /// Defaults to `false` because the engine doesn't yet know how to
216    /// produce one; Phase D might flip the default.
217    #[serde(default, skip_serializing_if = "is_false")]
218    pub include_summary: bool,
219
220    /// Merge method to use when `action: auto-merge`. If unset, the SCM
221    /// plugin's default is used.
222    #[serde(
223        default,
224        rename = "merge_method",
225        alias = "merge-method",
226        skip_serializing_if = "Option::is_none"
227    )]
228    pub merge_method: Option<MergeMethod>,
229}
230
231impl ReactionConfig {
232    /// Convenience constructor for tests and Phase D wiring. Mirrors the
233    /// minimum useful config (`auto: true`, action set, everything else
234    /// default).
235    pub fn new(action: ReactionAction) -> Self {
236        Self {
237            auto: true,
238            action,
239            message: None,
240            priority: None,
241            retries: None,
242            escalate_after: None,
243            threshold: None,
244            include_summary: false,
245            merge_method: None,
246        }
247    }
248}
249
250/// Outcome of a single reaction dispatch. Kept in Phase A so the engine
251/// in Phase D has a stable return shape to target. Mirrors
252/// `ReactionResult` in the TS reference.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct ReactionOutcome {
255    /// Reaction key that fired (e.g. `"ci-failed"`).
256    pub reaction_type: String,
257    /// Did the configured action succeed? `false` means it either
258    /// errored or was a no-op because `auto: false`.
259    pub success: bool,
260    /// Action that was *actually* taken — may differ from the configured
261    /// action if the engine escalated mid-flight (e.g. `SendToAgent` →
262    /// `Notify` after `retries` were exhausted).
263    pub action: ReactionAction,
264    /// Message delivered, if any. Useful for tests and for CLI echoing.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub message: Option<String>,
267    /// `true` if the engine decided to escalate rather than retry.
268    pub escalated: bool,
269}
270
271fn default_auto() -> bool {
272    true
273}
274
275fn is_true(b: &bool) -> bool {
276    *b
277}
278
279fn is_false(b: &bool) -> bool {
280    !*b
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn reaction_action_uses_kebab_case() {
289        assert_eq!(
290            serde_yaml::to_string(&ReactionAction::SendToAgent)
291                .unwrap()
292                .trim(),
293            "send-to-agent"
294        );
295        assert_eq!(
296            serde_yaml::to_string(&ReactionAction::AutoMerge)
297                .unwrap()
298                .trim(),
299            "auto-merge"
300        );
301
302        let parsed: ReactionAction = serde_yaml::from_str("notify").unwrap();
303        assert_eq!(parsed, ReactionAction::Notify);
304    }
305
306    #[test]
307    fn event_priority_uses_snake_case() {
308        let yaml = serde_yaml::to_string(&EventPriority::Urgent).unwrap();
309        assert_eq!(yaml.trim(), "urgent");
310
311        let parsed: EventPriority = serde_yaml::from_str("warning").unwrap();
312        assert_eq!(parsed, EventPriority::Warning);
313    }
314
315    #[test]
316    fn default_priority_for_reaction_key_matches_supported_keys() {
317        assert_eq!(
318            default_priority_for_reaction_key("ci-failed"),
319            EventPriority::Warning
320        );
321        assert_eq!(
322            default_priority_for_reaction_key("changes-requested"),
323            EventPriority::Info
324        );
325        assert_eq!(
326            default_priority_for_reaction_key("merge-conflicts"),
327            EventPriority::Warning
328        );
329        assert_eq!(
330            default_priority_for_reaction_key("approved-and-green"),
331            EventPriority::Action
332        );
333        assert_eq!(
334            default_priority_for_reaction_key("agent-idle"),
335            EventPriority::Info
336        );
337        assert_eq!(
338            default_priority_for_reaction_key("agent-stuck"),
339            EventPriority::Urgent
340        );
341        assert_eq!(
342            default_priority_for_reaction_key("agent-needs-input"),
343            EventPriority::Urgent
344        );
345        assert_eq!(
346            default_priority_for_reaction_key("agent-exited"),
347            EventPriority::Urgent
348        );
349        assert_eq!(
350            default_priority_for_reaction_key("all-complete"),
351            EventPriority::Info
352        );
353        assert_eq!(
354            default_priority_for_reaction_key("not-a-real-key"),
355            EventPriority::Warning
356        );
357    }
358
359    #[test]
360    fn escalate_after_number_parses_as_attempts() {
361        let parsed: EscalateAfter = serde_yaml::from_str("3").unwrap();
362        assert_eq!(parsed, EscalateAfter::Attempts(3));
363    }
364
365    #[test]
366    fn escalate_after_string_parses_as_duration() {
367        let parsed: EscalateAfter = serde_yaml::from_str("10m").unwrap();
368        assert_eq!(parsed, EscalateAfter::Duration("10m".into()));
369    }
370
371    #[test]
372    fn escalate_after_attempts_roundtrips() {
373        let e = EscalateAfter::Attempts(5);
374        let yaml = serde_yaml::to_string(&e).unwrap();
375        // Untagged enum means the raw number, no wrapper key.
376        assert_eq!(yaml.trim(), "5");
377        let back: EscalateAfter = serde_yaml::from_str(&yaml).unwrap();
378        assert_eq!(e, back);
379    }
380
381    #[test]
382    fn reaction_config_minimal_config_deserializes() {
383        // Only `action` is required; everything else defaults.
384        let yaml = "action: notify\n";
385        let cfg: ReactionConfig = serde_yaml::from_str(yaml).unwrap();
386        assert_eq!(cfg.action, ReactionAction::Notify);
387        assert!(cfg.auto); // default_auto
388        assert_eq!(cfg.retries, None);
389        assert!(!cfg.include_summary);
390    }
391
392    #[test]
393    fn reaction_config_full_config_roundtrips() {
394        let yaml = r#"
395auto: true
396action: send-to-agent
397message: "CI broke — logs attached, please fix."
398priority: action
399retries: 3
400escalate_after: 3
401threshold: 5m
402include_summary: true
403"#;
404        let cfg: ReactionConfig = serde_yaml::from_str(yaml).unwrap();
405        assert_eq!(cfg.action, ReactionAction::SendToAgent);
406        assert_eq!(cfg.priority, Some(EventPriority::Action));
407        assert_eq!(cfg.retries, Some(3));
408        assert_eq!(cfg.escalate_after, Some(EscalateAfter::Attempts(3)));
409        assert_eq!(cfg.threshold.as_deref(), Some("5m"));
410        assert!(cfg.include_summary);
411
412        // Re-serialize and re-parse — fields survive a round trip.
413        let back: ReactionConfig =
414            serde_yaml::from_str(&serde_yaml::to_string(&cfg).unwrap()).unwrap();
415        assert_eq!(cfg, back);
416    }
417
418    #[test]
419    fn reaction_config_accepts_hyphenated_escalate_after_key() {
420        // Config files in the wild will write `escalate-after:` more
421        // often than `escalate_after:`. Serde `alias` makes both work,
422        // but the canonical write-back form uses the underscore rename.
423        let yaml = "action: notify\nescalate-after: 10m\n";
424        let cfg: ReactionConfig = serde_yaml::from_str(yaml).unwrap();
425        assert_eq!(
426            cfg.escalate_after,
427            Some(EscalateAfter::Duration("10m".into()))
428        );
429    }
430
431    #[test]
432    fn reaction_config_canonicalizes_escalate_after_on_write() {
433        // The alias → rename contract: we accept `escalate-after:` on
434        // read but always emit `escalate_after:` on write. This nails
435        // it explicitly — without this test a stray `#[serde(alias)]`
436        // change that flipped which form is canonical would go unnoticed.
437        let yaml_in = "action: notify\nescalate-after: 10m\n";
438        let cfg: ReactionConfig = serde_yaml::from_str(yaml_in).unwrap();
439        let yaml_out = serde_yaml::to_string(&cfg).unwrap();
440        assert!(
441            yaml_out.contains("escalate_after:"),
442            "expected canonical snake_case key in output, got:\n{yaml_out}"
443        );
444        assert!(
445            !yaml_out.contains("escalate-after:"),
446            "expected no kebab-case key in output, got:\n{yaml_out}"
447        );
448    }
449
450    #[test]
451    fn reaction_config_auto_true_is_omitted_on_write() {
452        // Default-valued fields (`auto: true`, `include_summary: false`)
453        // are elided on write so a minimal config round-trips to a
454        // minimal config. Guard against a future field being added
455        // without matching `skip_serializing_if` and silently bloating
456        // every config write.
457        let cfg = ReactionConfig::new(ReactionAction::Notify);
458        let yaml = serde_yaml::to_string(&cfg).unwrap();
459        assert!(
460            !yaml.contains("auto:"),
461            "auto:true should be omitted, got:\n{yaml}"
462        );
463        assert!(
464            !yaml.contains("include_summary"),
465            "include_summary:false should be omitted, got:\n{yaml}"
466        );
467        // But `auto: false` must still serialize (it's a deliberate disable).
468        let mut off = cfg;
469        off.auto = false;
470        let yaml = serde_yaml::to_string(&off).unwrap();
471        assert!(
472            yaml.contains("auto: false"),
473            "auto:false must survive, got:\n{yaml}"
474        );
475    }
476
477    #[test]
478    fn escalate_after_duration_preserves_whitespace_verbatim() {
479        // Phase D's duration parser will need to handle (or reject)
480        // strings like "3 " with trailing whitespace. This test locks
481        // in that Phase A's deserializer does NOT pre-trim — so the
482        // parser later has a clear contract.
483        let parsed: EscalateAfter = serde_yaml::from_str(r#""3 ""#).unwrap();
484        assert_eq!(parsed, EscalateAfter::Duration("3 ".into()));
485    }
486
487    #[test]
488    fn reaction_config_new_is_minimal() {
489        let c = ReactionConfig::new(ReactionAction::AutoMerge);
490        assert!(c.auto);
491        assert_eq!(c.action, ReactionAction::AutoMerge);
492        assert!(c.message.is_none());
493        assert!(c.retries.is_none());
494    }
495
496    #[test]
497    fn reaction_outcome_escalated_roundtrips() {
498        let o = ReactionOutcome {
499            reaction_type: "ci-failed".into(),
500            success: true,
501            action: ReactionAction::Notify,
502            message: Some("escalated after 3 attempts".into()),
503            escalated: true,
504        };
505        let back: ReactionOutcome =
506            serde_yaml::from_str(&serde_yaml::to_string(&o).unwrap()).unwrap();
507        assert_eq!(o, back);
508    }
509}