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}