oxicode_agent/advisor/
channels.rs1use crate::advisor::types::{
9 ADVISOR_GUIDANCE, AdvisorDeliveryChannel, AdvisorNote, AdvisorSeverity, DeliveryOpts,
10};
11
12#[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#[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#[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#[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#[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("&"),
98 '<' => out.push_str("<"),
99 '>' => out.push_str(">"),
100 '"' => out.push_str("""),
101 '\'' => out.push_str("'"),
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 assert!(!is_immune_turn_active(5, None, 3));
125 assert!(!is_immune_turn_active(0, Some(0), 0));
127 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 assert_eq!(
167 resolve_delivery_channel(DeliveryOpts {
168 severity: Some(AdvisorSeverity::Blocker),
169 auto_resume_suppressed: true,
170 streaming: false, aborting: false,
172 ..Default::default()
173 }),
174 AdvisorDeliveryChannel::Preserve
175 );
176 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 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, },
224 ];
225 let out = format_advisory_batch(¬es);
226 assert!(
227 out.contains("<advisory severity=\"blocker\" guidance=\"weigh, don't blindly obey\">")
228 );
229 assert!(out.contains("Stop."));
230 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(¬es);
241 assert!(out.contains("<"));
242 assert!(out.contains("&"));
243 assert!(out.contains(">"));
244 assert!(out.contains("""));
245 assert!(!out.contains(" < ") || out.matches("<").count() >= 1);
246 }
247
248 #[test]
249 fn empty_batch_is_empty() {
250 assert_eq!(format_advisory_batch(&[]), "");
251 }
252}