1use async_trait::async_trait;
13use serde::Deserialize;
14use serde_json::json;
15use std::sync::Arc;
16
17use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
18
19const MAX_TITLE_CHARS: usize = 100;
21const MAX_MESSAGE_CHARS: usize = 500;
23
24pub trait NotificationDispatcher: Send + Sync {
35 fn dispatch(
39 &self,
40 session_id: &str,
41 title: &str,
42 body: &str,
43 priority: &str,
44 url: Option<&str>,
45 );
46}
47
48#[derive(Debug, Deserialize)]
49struct NotifyArgs {
50 title: String,
51 message: String,
52 #[serde(default)]
53 priority: Option<String>,
54 #[serde(default)]
55 url: Option<String>,
56}
57
58pub struct NotifyTool {
61 dispatcher: Arc<dyn NotificationDispatcher>,
62}
63
64impl NotifyTool {
65 pub fn new(dispatcher: Arc<dyn NotificationDispatcher>) -> Self {
66 Self { dispatcher }
67 }
68}
69
70#[async_trait]
71impl Tool for NotifyTool {
72 fn name(&self) -> &str {
73 "notify"
74 }
75
76 fn description(&self) -> &str {
77 "Proactively alert the human owner OUTSIDE this conversation (OS desktop popup and/or any push channels \
78 they've configured), for things worth interrupting them for even if they are not watching this session \
79 right now: a scheduled reminder firing, a long-running task finishing, or something that needs their \
80 attention. Do NOT use this for a normal conversational reply — that already reaches the user through the \
81 chat transcript itself. Use it sparingly, only when the message has standalone value outside the chat."
82 }
83
84 fn parameters_schema(&self) -> serde_json::Value {
85 json!({
86 "type": "object",
87 "properties": {
88 "title": {
89 "type": "string",
90 "description": "Short notification title (at most 100 characters)."
91 },
92 "message": {
93 "type": "string",
94 "description": "Notification body (at most 500 characters)."
95 },
96 "priority": {
97 "type": "string",
98 "enum": ["low", "normal", "high"],
99 "description": "Urgency of the notification. Defaults to \"normal\"."
100 },
101 "url": {
102 "type": "string",
103 "description": "Optional click-through URL/deep-link surfaced by push channels that support one."
104 }
105 },
106 "required": ["title", "message"]
107 })
108 }
109
110 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
111 ToolClass::READONLY_PARALLEL
116 }
117
118 async fn invoke(
119 &self,
120 args: serde_json::Value,
121 ctx: ToolCtx,
122 ) -> Result<ToolOutcome, ToolError> {
123 let session_id = ctx
124 .session_id()
125 .ok_or_else(|| {
126 ToolError::Execution("notify requires a session_id in tool context".to_string())
127 })?
128 .to_string();
129
130 let parsed: NotifyArgs = serde_json::from_value(args)
131 .map_err(|e| ToolError::InvalidArguments(format!("Invalid notify args: {e}")))?;
132
133 let title = parsed.title.trim();
134 if title.is_empty() {
135 return Err(ToolError::InvalidArguments(
136 "notify: `title` must not be empty".to_string(),
137 ));
138 }
139 if title.chars().count() > MAX_TITLE_CHARS {
140 return Err(ToolError::InvalidArguments(format!(
141 "notify: `title` must be at most {MAX_TITLE_CHARS} characters (got {})",
142 title.chars().count()
143 )));
144 }
145
146 let message = parsed.message.trim();
147 if message.is_empty() {
148 return Err(ToolError::InvalidArguments(
149 "notify: `message` must not be empty".to_string(),
150 ));
151 }
152 if message.chars().count() > MAX_MESSAGE_CHARS {
153 return Err(ToolError::InvalidArguments(format!(
154 "notify: `message` must be at most {MAX_MESSAGE_CHARS} characters (got {})",
155 message.chars().count()
156 )));
157 }
158
159 let priority = match parsed.priority.as_deref() {
160 None => "normal",
161 Some(p) if p.eq_ignore_ascii_case("low") => "low",
162 Some(p) if p.eq_ignore_ascii_case("normal") => "normal",
163 Some(p) if p.eq_ignore_ascii_case("high") => "high",
164 Some(other) => {
165 return Err(ToolError::InvalidArguments(format!(
166 "notify: `priority` must be one of low|normal|high (got \"{other}\")"
167 )))
168 }
169 };
170
171 let url = parsed
172 .url
173 .as_deref()
174 .map(str::trim)
175 .filter(|s| !s.is_empty());
176
177 self.dispatcher
178 .dispatch(&session_id, title, message, priority, url);
179
180 Ok(ToolOutcome::Completed(ToolResult {
181 success: true,
182 result: format!(
183 "Notification \"{title}\" (priority: {priority}) sent to every notification channel currently \
184 enabled in settings (in-app plus any configured desktop/push channels). This confirms the request \
185 was dispatched, not that it was delivered or read."
186 ),
187 display_preference: Some("Collapsible".to_string()),
188 images: Vec::new(),
189 }))
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::sync::Mutex;
197
198 #[derive(Default)]
199 struct RecordedCall {
200 session_id: String,
201 title: String,
202 body: String,
203 priority: String,
204 url: Option<String>,
205 }
206
207 #[derive(Default)]
208 struct MockDispatcher {
209 calls: Mutex<Vec<RecordedCall>>,
210 }
211
212 impl NotificationDispatcher for MockDispatcher {
213 fn dispatch(
214 &self,
215 session_id: &str,
216 title: &str,
217 body: &str,
218 priority: &str,
219 url: Option<&str>,
220 ) {
221 self.calls.lock().unwrap().push(RecordedCall {
222 session_id: session_id.to_string(),
223 title: title.to_string(),
224 body: body.to_string(),
225 priority: priority.to_string(),
226 url: url.map(str::to_string),
227 });
228 }
229 }
230
231 fn ctx() -> ToolCtx {
232 let mut ctx = ToolCtx::none("call-1");
233 ctx.session_id = Some(std::sync::Arc::from("sess-1"));
234 ctx
235 }
236
237 #[test]
238 fn tool_name_is_notify() {
239 let dispatcher = Arc::new(MockDispatcher::default());
240 let tool = NotifyTool::new(dispatcher);
241 assert_eq!(tool.name(), "notify");
242 }
243
244 #[test]
245 fn schema_requires_title_and_message() {
246 let dispatcher = Arc::new(MockDispatcher::default());
247 let tool = NotifyTool::new(dispatcher);
248 let schema = tool.parameters_schema();
249 let required: Vec<&str> = schema["required"]
250 .as_array()
251 .unwrap()
252 .iter()
253 .map(|v| v.as_str().unwrap())
254 .collect();
255 assert!(required.contains(&"title"));
256 assert!(required.contains(&"message"));
257 assert!(!required.contains(&"priority"));
258 assert!(!required.contains(&"url"));
259 }
260
261 #[test]
262 fn classify_is_readonly_parallel() {
263 let dispatcher = Arc::new(MockDispatcher::default());
264 let tool = NotifyTool::new(dispatcher);
265 assert_eq!(tool.classify(&json!({})), ToolClass::READONLY_PARALLEL);
266 }
267
268 #[tokio::test]
269 async fn invoke_dispatches_with_defaults_and_confirms() {
270 let dispatcher = Arc::new(MockDispatcher::default());
271 let tool = NotifyTool::new(dispatcher.clone());
272
273 let out = tool
274 .invoke(json!({"title": "Reminder", "message": "Stand up"}), ctx())
275 .await
276 .expect("invoke should succeed");
277 let ToolOutcome::Completed(result) = out else {
278 panic!("expected Completed")
279 };
280 assert!(result.success);
281 assert!(result.result.contains("Reminder"));
282 assert!(result.result.contains("normal"));
283
284 let calls = dispatcher.calls.lock().unwrap();
285 assert_eq!(calls.len(), 1);
286 assert_eq!(calls[0].session_id, "sess-1");
287 assert_eq!(calls[0].title, "Reminder");
288 assert_eq!(calls[0].body, "Stand up");
289 assert_eq!(calls[0].priority, "normal");
290 assert_eq!(calls[0].url, None);
291 }
292
293 #[tokio::test]
294 async fn invoke_forwards_explicit_priority_and_url() {
295 let dispatcher = Arc::new(MockDispatcher::default());
296 let tool = NotifyTool::new(dispatcher.clone());
297
298 tool.invoke(
299 json!({
300 "title": "Build finished",
301 "message": "The nightly build finished with 0 failures.",
302 "priority": "HIGH",
303 "url": "bamboo://session/sess-1"
304 }),
305 ctx(),
306 )
307 .await
308 .expect("invoke should succeed");
309
310 let calls = dispatcher.calls.lock().unwrap();
311 assert_eq!(calls[0].priority, "high");
312 assert_eq!(calls[0].url.as_deref(), Some("bamboo://session/sess-1"));
313 }
314
315 #[tokio::test]
316 async fn invoke_rejects_missing_title() {
317 let dispatcher = Arc::new(MockDispatcher::default());
318 let tool = NotifyTool::new(dispatcher);
319 let result = tool.invoke(json!({"message": "hi"}), ctx()).await;
320 assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
321 }
322
323 #[tokio::test]
324 async fn invoke_rejects_missing_message() {
325 let dispatcher = Arc::new(MockDispatcher::default());
326 let tool = NotifyTool::new(dispatcher);
327 let result = tool.invoke(json!({"title": "hi"}), ctx()).await;
328 assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
329 }
330
331 #[tokio::test]
332 async fn invoke_rejects_oversize_title() {
333 let dispatcher = Arc::new(MockDispatcher::default());
334 let tool = NotifyTool::new(dispatcher);
335 let long_title = "x".repeat(MAX_TITLE_CHARS + 1);
336 let result = tool
337 .invoke(json!({"title": long_title, "message": "hi"}), ctx())
338 .await;
339 assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
340 }
341
342 #[tokio::test]
343 async fn invoke_rejects_oversize_message() {
344 let dispatcher = Arc::new(MockDispatcher::default());
345 let tool = NotifyTool::new(dispatcher);
346 let long_message = "x".repeat(MAX_MESSAGE_CHARS + 1);
347 let result = tool
348 .invoke(json!({"title": "hi", "message": long_message}), ctx())
349 .await;
350 assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
351 }
352
353 #[tokio::test]
354 async fn invoke_rejects_empty_title_after_trim() {
355 let dispatcher = Arc::new(MockDispatcher::default());
356 let tool = NotifyTool::new(dispatcher);
357 let result = tool
358 .invoke(json!({"title": " ", "message": "hi"}), ctx())
359 .await;
360 assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
361 }
362
363 #[tokio::test]
364 async fn invoke_rejects_invalid_priority() {
365 let dispatcher = Arc::new(MockDispatcher::default());
366 let tool = NotifyTool::new(dispatcher);
367 let result = tool
368 .invoke(
369 json!({"title": "hi", "message": "hi", "priority": "urgent"}),
370 ctx(),
371 )
372 .await;
373 assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
374 }
375
376 #[tokio::test]
377 async fn invoke_rejects_missing_session_id() {
378 let dispatcher = Arc::new(MockDispatcher::default());
379 let tool = NotifyTool::new(dispatcher);
380 let result = tool
381 .invoke(
382 json!({"title": "hi", "message": "hi"}),
383 ToolCtx::none("call-1"),
384 )
385 .await;
386 assert!(matches!(result, Err(ToolError::Execution(_))));
387 }
388
389 #[tokio::test]
390 async fn invoke_treats_blank_url_as_absent() {
391 let dispatcher = Arc::new(MockDispatcher::default());
392 let tool = NotifyTool::new(dispatcher.clone());
393 tool.invoke(json!({"title": "hi", "message": "hi", "url": " "}), ctx())
394 .await
395 .expect("invoke should succeed");
396 let calls = dispatcher.calls.lock().unwrap();
397 assert_eq!(calls[0].url, None);
398 }
399}