返回 CodeWhale
notify.rs
根目录 / crates / tui / src / tools / notify.rs
1 //! `notify` tool — model-callable desktop notification (#1322).
2 //!
3 //! Routes through the existing `tui::notifications` infrastructure (OSC 9
4 //! for known capable terminals, BEL fallback on macOS / Linux, `MessageBeep`
5 //! on Windows when explicitly opted in). The model decides when to fire —
6 //! the tool is intended for "long task done, come back" beats and
7 //! sub-agent-completion pings, not chatter.
8 //!
9 //! Honors the user's `[notifications]` config: `method = "off"` silences
10 //! the tool entirely, and `quiet` / `events.model-notify = false` gate the
11 //! category through the process-wide [`NotificationGate`]. Output messages
12 //! are length-capped so a runaway model can't paint a paragraph into the
13 //! terminal title bar.
14
15 use async_trait::async_trait;
16 use serde_json::{Value, json};
17
18 use super::spec::{
19 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
20 optional_str, required_str,
21 };
22 use crate::tui::notifications::{NotificationPayload, notify_done};
23
24 /// Maximum chars passed through for the title — keeps the OSC 9 escape
25 /// reasonable on terminals that wrap long titles awkwardly.
26 const NOTIFY_TITLE_CAP: usize = 80;
27 /// Maximum chars passed through for the body. Most receivers truncate
28 /// past ~120, so 200 leaves headroom while still bounded.
29 const NOTIFY_BODY_CAP: usize = 200;
30
31 /// Tool that fires a single desktop notification.
32 pub struct NotifyTool;
33
34 #[async_trait]
35 impl ToolSpec for NotifyTool {
36 fn name(&self) -> &'static str {
37 "notify"
38 }
39
40 fn description(&self) -> &'static str {
41 "Send a desktop notification only when the user must act: a long task \
42 completed, a blocking error needs a decision, or progress cannot \
43 continue without an answer. Never notify for routine progress, \
44 acknowledgements, or liveness. Pass a short `title` and optional \
45 `body`. Users can silence everything with \
46 `[notifications].method = \"off\"` or `[notifications].quiet = true`, \
47 or this category with `[notifications.events].model-notify = false`; \
48 disabled notifications are silent no-ops."
49 }
50
51 fn input_schema(&self) -> Value {
52 json!({
53 "type": "object",
54 "properties": {
55 "title": {
56 "type": "string",
57 "description": "Short notification title (≤ 80 chars after truncation). Required."
58 },
59 "body": {
60 "type": "string",
61 "description": "Optional longer body (≤ 200 chars after truncation)."
62 }
63 },
64 "required": ["title"]
65 })
66 }
67
68 fn capabilities(&self) -> Vec<ToolCapability> {
69 // No filesystem or shell side effects; the only output is a single
70 // terminal-escape write to stdout. Mark as ReadOnly so the
71 // approval-requirement default is `Auto` and the tool routes
72 // through without prompting.
73 vec![ToolCapability::ReadOnly]
74 }
75
76 fn approval_requirement(&self) -> ApprovalRequirement {
77 ApprovalRequirement::Auto
78 }
79
80 async fn execute(&self, input: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> {
81 let title_raw = required_str(&input, "title")?;
82 let body_raw = optional_str(&input, "body")?.unwrap_or("");
83
84 // Char-bounded truncation (not byte-bounded) so we don't slice
85 // through a multi-byte sequence and emit invalid UTF-8 to the
86 // terminal.
87 let title: String = title_raw.chars().take(NOTIFY_TITLE_CAP).collect();
88 let body: String = body_raw.chars().take(NOTIFY_BODY_CAP).collect();
89 let title = title.trim();
90 let body = body.trim();
91
92 if title.is_empty() {
93 return Err(ToolError::execution_failed("title must not be empty"));
94 }
95
96 // #4834: model-authored text is the least trusted input that can
97 // reach Notification Center, so it goes through the typed payload
98 // like every other event kind — bounded, control-byte-stripped,
99 // and redacted for credentials, absolute paths, and raw tool JSON.
100 let payload = NotificationPayload::model_notify(
101 title,
102 if body.is_empty() { None } else { Some(body) },
103 );
104
105 let in_tmux = std::env::var("TMUX")
106 .map(|v| !v.is_empty())
107 .unwrap_or(false);
108
109 // Threshold = 0 so the notification always fires; the model has
110 // already decided this is the moment.
111 let outcome = notify_done(
112 crate::tui::notifications::configured_method(),
113 in_tmux,
114 &payload,
115 std::time::Duration::ZERO,
116 std::time::Duration::from_secs(1),
117 );
118
119 Ok(ToolResult::success(format!(
120 "{}: {title}",
121 outcome.receipt()
122 )))
123 }
124 }
125
126 #[cfg(test)]
127 mod tests {
128 use super::*;
129 use crate::tui::notifications::{
130 Method, configured_method, current_notification_gate, install_configured_method,
131 notify_done_to,
132 };
133 use std::path::Path;
134
135 fn ctx() -> ToolContext {
136 ToolContext::new(Path::new("."))
137 }
138
139 #[tokio::test]
140 async fn rejects_missing_title() {
141 let err = NotifyTool.execute(json!({}), &ctx()).await.unwrap_err();
142 assert!(err.to_string().to_lowercase().contains("title"), "{err}");
143 }
144
145 #[tokio::test]
146 async fn rejects_empty_title_after_trim() {
147 let err = NotifyTool
148 .execute(json!({"title": " "}), &ctx())
149 .await
150 .unwrap_err();
151 assert!(
152 err.to_string().to_lowercase().contains("must not be empty"),
153 "{err}"
154 );
155 }
156
157 #[tokio::test]
158 async fn truncates_title_to_cap() {
159 let long = "x".repeat(500);
160 let result = NotifyTool
161 .execute(json!({"title": long}), &ctx())
162 .await
163 .expect("ok");
164 // Confirmation message echoes the *truncated* title.
165 let echo_x_count = result.content.matches('x').count();
166 assert_eq!(echo_x_count, NOTIFY_TITLE_CAP);
167 }
168
169 #[tokio::test]
170 async fn accepts_body_optional() {
171 let result = NotifyTool
172 .execute(json!({"title": "done", "body": "tests pass"}), &ctx())
173 .await
174 .expect("ok");
175 assert!(result.success);
176 assert!(result.content.contains("done"));
177 }
178
179 #[tokio::test]
180 async fn safe_against_multibyte_truncation() {
181 // Construct a title whose char-count is below the cap but whose
182 // byte-count would be above a naive byte cap; assert no panic
183 // and the success-content roundtrips the title intact.
184 let title: String = "我".repeat(30); // 30 chars × 3 bytes = 90 bytes, < 80 chars cap (well, == 30 chars)
185 let result = NotifyTool
186 .execute(json!({"title": title.clone()}), &ctx())
187 .await
188 .expect("ok");
189 assert!(result.content.contains(&title));
190 }
191
192 #[test]
193 fn schema_exposes_title_and_body_fields() {
194 let schema = NotifyTool.input_schema();
195 let props = schema.get("properties").unwrap();
196 assert!(props.get("title").is_some());
197 assert!(props.get("body").is_some());
198 let required = schema.get("required").unwrap().as_array().unwrap();
199 assert!(required.iter().any(|v| v.as_str() == Some("title")));
200 assert!(!required.iter().any(|v| v.as_str() == Some("body")));
201 }
202
203 /// Restores the process-wide configured method after a test mutates it,
204 /// mirroring `NotificationGateRestore` in `tui::notifications`.
205 struct ConfiguredMethodRestore(Method);
206
207 impl ConfiguredMethodRestore {
208 fn capture() -> Self {
209 Self(configured_method())
210 }
211 }
212
213 impl Drop for ConfiguredMethodRestore {
214 fn drop(&mut self) {
215 install_configured_method(self.0);
216 }
217 }
218
219 #[test]
220 fn configured_method_off_silences_the_tool_emission() {
221 let _restore = ConfiguredMethodRestore::capture();
222 install_configured_method(Method::Off);
223
224 // The emission chain `execute` drives: the installed method decides
225 // suppression before any sink write, with the gate loaded from the
226 // process-wide state.
227 let payload = NotificationPayload::model_notify("done", None);
228 let mut sink = Vec::new();
229 notify_done_to(
230 configured_method(),
231 false,
232 &payload,
233 std::time::Duration::ZERO,
234 std::time::Duration::from_secs(1),
235 current_notification_gate(),
236 &mut sink,
237 );
238 assert!(
239 sink.is_empty(),
240 "configured method=off must silence the notify tool path"
241 );
242 }
243
244 #[tokio::test]
245 async fn configured_method_off_still_reports_success_to_the_model() {
246 // The description promises a *silent* no-op: the model sees success
247 // (nothing to retry), the user's desktop stays quiet.
248 let _restore = ConfiguredMethodRestore::capture();
249 install_configured_method(Method::Off);
250
251 let result = NotifyTool
252 .execute(json!({"title": "done"}), &ctx())
253 .await
254 .expect("ok");
255 assert!(result.success);
256 assert!(result.content.contains("done"));
257 }
258 }
259
259 lines RUST