Skip to main content

oxicode_agent/advisor/
channels.rs

1//! Delivery-channel resolution + `<advisory>` rendering — pure functions
2//! ported from omp `advise-tool.ts`.
3//!
4//! # Attribution
5//!
6//! Translated to Rust from omp (oh-my-pi), MIT licensed.
7
8use crate::advisor::types::{
9    ADVISOR_GUIDANCE, AdvisorDeliveryChannel, AdvisorNote, AdvisorSeverity, DeliveryOpts,
10};
11
12/// Whether advice at this severity should interrupt the running agent
13/// (delivered via the steering channel) rather than ride the non-interrupting
14/// aside queue. `concern` and `blocker` interrupt; a plain `nit` queues.
15/// omp `isInterruptingSeverity`.
16#[must_use]
17pub fn is_interrupting_severity(severity: Option<AdvisorSeverity>) -> bool {
18    matches!(
19        severity,
20        Some(AdvisorSeverity::Concern | AdvisorSeverity::Blocker)
21    )
22}
23
24/// Half-open turn-count fence `[start, start + turns)` for the post-interrupt
25/// cooldown. omp `isAdvisorInterruptImmuneTurnActive`.
26#[must_use]
27pub fn is_immune_turn_active(
28    completed_turns: u64,
29    immune_start: Option<u64>,
30    immune_turns: u64,
31) -> bool {
32    let Some(start) = immune_start else {
33        return false;
34    };
35    if immune_turns == 0 {
36        return false;
37    }
38    completed_turns < start.saturating_add(immune_turns)
39}
40
41/// Decide how one advisor note reaches the primary agent. omp
42/// `resolveAdvisorDeliveryChannel`.
43///
44/// - A non-interrupting `nit` always rides the aside queue.
45/// - An interrupting `concern`/`blocker` is normally steered into the agent.
46/// - After a deliberate user interrupt (`auto_resume_suppressed`) the advisor
47///   must not auto-resume the stopped run; while the agent is idle or still
48///   tearing the interrupted turn down (`aborting`) the note is preserved as a
49///   visible card instead of restarting the run.
50/// - During the post-interrupt immune-turn window, further `concern`/`blocker`
51///   notes are downgraded to asides.
52#[must_use]
53pub fn resolve_delivery_channel(opts: DeliveryOpts) -> AdvisorDeliveryChannel {
54    if !is_interrupting_severity(opts.severity) {
55        return AdvisorDeliveryChannel::Aside;
56    }
57    if opts.auto_resume_suppressed && (opts.aborting || !opts.streaming) {
58        return AdvisorDeliveryChannel::Preserve;
59    }
60    if opts.interrupt_immune_turn_active {
61        return AdvisorDeliveryChannel::Aside;
62    }
63    AdvisorDeliveryChannel::Steer
64}
65
66/// Render a batch of advisor notes as the agent-facing message body: one
67/// `<advisory>` element per note, `severity` as an attribute (omitted for a
68/// plain nit), `guidance` always present. omp `formatAdvisorBatchContent`.
69#[must_use]
70pub fn format_advisory_batch(notes: &[AdvisorNote]) -> String {
71    if notes.is_empty() {
72        return String::new();
73    }
74    let mut parts: Vec<String> = Vec::with_capacity(notes.len());
75    for n in notes {
76        let severity_attr = match n.severity {
77            Some(s) => format!(" severity=\"{}\"", s.as_str()),
78            None => String::new(),
79        };
80        parts.push(format!(
81            "<advisory{severity_attr} guidance=\"{g}\">\n{note}\n</advisory>",
82            severity_attr = severity_attr,
83            g = ADVISOR_GUIDANCE,
84            note = escape_xml_text(&n.note)
85        ));
86    }
87    parts.join("\n")
88}
89
90/// Escape the five XML-significant characters for safe embedding in element
91/// text. omp `escapeXmlText`.
92#[must_use]
93pub(crate) fn escape_xml_text(s: &str) -> String {
94    let mut out = String::with_capacity(s.len());
95    for c in s.chars() {
96        match c {
97            '&' => out.push_str("&amp;"),
98            '<' => out.push_str("&lt;"),
99            '>' => out.push_str("&gt;"),
100            '"' => out.push_str("&quot;"),
101            '\'' => out.push_str("&apos;"),
102            _ => out.push(c),
103        }
104    }
105    out
106}
107
108#[cfg(test)]
109mod tests {
110    #![allow(clippy::unwrap_used)]
111    use super::*;
112
113    #[test]
114    fn nit_is_non_interrupting() {
115        assert!(!is_interrupting_severity(None));
116        assert!(!is_interrupting_severity(Some(AdvisorSeverity::Nit)));
117        assert!(is_interrupting_severity(Some(AdvisorSeverity::Concern)));
118        assert!(is_interrupting_severity(Some(AdvisorSeverity::Blocker)));
119    }
120
121    #[test]
122    fn immune_fence_is_half_open() {
123        // no start -> inactive
124        assert!(!is_immune_turn_active(5, None, 3));
125        // zero turns -> inactive
126        assert!(!is_immune_turn_active(0, Some(0), 0));
127        // [start, start+turns): start=10, turns=2 -> active at 10,11; inactive at 12
128        assert!(is_immune_turn_active(10, Some(10), 2));
129        assert!(is_immune_turn_active(11, Some(10), 2));
130        assert!(!is_immune_turn_active(12, Some(10), 2));
131    }
132
133    #[test]
134    fn channel_nit_always_aside() {
135        for &streaming in &[false, true] {
136            for &supp in &[false, true] {
137                assert_eq!(
138                    resolve_delivery_channel(DeliveryOpts {
139                        severity: Some(AdvisorSeverity::Nit),
140                        auto_resume_suppressed: supp,
141                        streaming,
142                        aborting: false,
143                        interrupt_immune_turn_active: false,
144                    }),
145                    AdvisorDeliveryChannel::Aside
146                );
147            }
148        }
149    }
150
151    #[test]
152    fn channel_concern_live_steers() {
153        assert_eq!(
154            resolve_delivery_channel(DeliveryOpts {
155                severity: Some(AdvisorSeverity::Concern),
156                streaming: true,
157                ..Default::default()
158            }),
159            AdvisorDeliveryChannel::Steer
160        );
161    }
162
163    #[test]
164    fn channel_post_interrupt_idle_preserves() {
165        // user interrupted (auto_resume_suppressed), agent idle -> preserve
166        assert_eq!(
167            resolve_delivery_channel(DeliveryOpts {
168                severity: Some(AdvisorSeverity::Blocker),
169                auto_resume_suppressed: true,
170                streaming: false, // idle
171                aborting: false,
172                ..Default::default()
173            }),
174            AdvisorDeliveryChannel::Preserve
175        );
176        // but once streaming again (user resumed), steer does not auto-resume
177        assert_eq!(
178            resolve_delivery_channel(DeliveryOpts {
179                severity: Some(AdvisorSeverity::Blocker),
180                auto_resume_suppressed: true,
181                streaming: true,
182                aborting: false,
183                ..Default::default()
184            }),
185            AdvisorDeliveryChannel::Steer
186        );
187        // aborting + suppressed -> preserve even though we can't tell streaming yet
188        assert_eq!(
189            resolve_delivery_channel(DeliveryOpts {
190                severity: Some(AdvisorSeverity::Concern),
191                auto_resume_suppressed: true,
192                streaming: true,
193                aborting: true,
194                ..Default::default()
195            }),
196            AdvisorDeliveryChannel::Preserve
197        );
198    }
199
200    #[test]
201    fn channel_immune_downgrades_to_aside() {
202        assert_eq!(
203            resolve_delivery_channel(DeliveryOpts {
204                severity: Some(AdvisorSeverity::Blocker),
205                streaming: true,
206                interrupt_immune_turn_active: true,
207                ..Default::default()
208            }),
209            AdvisorDeliveryChannel::Aside
210        );
211    }
212
213    #[test]
214    fn batch_renders_advisory_elements() {
215        let notes = vec![
216            AdvisorNote {
217                note: "Stop.".into(),
218                severity: Some(AdvisorSeverity::Blocker),
219            },
220            AdvisorNote {
221                note: "rename x".into(),
222                severity: None, // plain nit -> no severity attr
223            },
224        ];
225        let out = format_advisory_batch(&notes);
226        assert!(
227            out.contains("<advisory severity=\"blocker\" guidance=\"weigh, don't blindly obey\">")
228        );
229        assert!(out.contains("Stop."));
230        // second note has no severity attribute
231        assert!(out.contains("<advisory guidance=\"weigh, don't blindly obey\">\nrename x"));
232    }
233
234    #[test]
235    fn batch_escapes_xml_significant_chars() {
236        let notes = vec![AdvisorNote {
237            note: "a < b & c > d \"e\"".into(),
238            severity: Some(AdvisorSeverity::Nit),
239        }];
240        let out = format_advisory_batch(&notes);
241        assert!(out.contains("&lt;"));
242        assert!(out.contains("&amp;"));
243        assert!(out.contains("&gt;"));
244        assert!(out.contains("&quot;"));
245        assert!(!out.contains(" < ") || out.matches("&lt;").count() >= 1);
246    }
247
248    #[test]
249    fn empty_batch_is_empty() {
250        assert_eq!(format_advisory_batch(&[]), "");
251    }
252}