返回 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 //! Auto-suppresses when `[notifications].method = "off"`. Output messages
10 //! are length-capped so a runaway model can't paint a paragraph into the
11 //! terminal title bar.
12
13 use async_trait::async_trait;
14 use serde_json::{Value, json};
15
16 use super::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
18 optional_str, required_str,
19 };
20 use crate::tui::notifications::{Method, NotificationPayload, notify_done};
21
22 /// Maximum chars passed through for the title — keeps the OSC 9 escape
23 /// reasonable on terminals that wrap long titles awkwardly.
24 const NOTIFY_TITLE_CAP: usize = 80;
25 /// Maximum chars passed through for the body. Most receivers truncate
26 /// past ~120, so 200 leaves headroom while still bounded.
27 const NOTIFY_BODY_CAP: usize = 200;
28
29 /// Tool that fires a single desktop notification.
30 pub struct NotifyTool;
31
32 #[async_trait]
33 impl ToolSpec for NotifyTool {
34 fn name(&self) -> &'static str {
35 "notify"
36 }
37
38 fn description(&self) -> &'static str {
39 "Send a desktop notification only when the user must act: a long task \
40 completed, a blocking error needs a decision, or progress cannot \
41 continue without an answer. Never notify for routine progress, \
42 acknowledgements, or liveness. Pass a short `title` and optional \
43 `body`. Users can silence everything with \
44 `[notifications].method = \"off\"` or `[notifications].quiet = true`, \
45 or this category with `[notifications.events].model-notify = false`; \
46 disabled notifications are silent no-ops."
47 }
48
49 fn input_schema(&self) -> Value {
50 json!({
51 "type": "object",
52 "properties": {
53 "title": {
54 "type": "string",
55 "description": "Short notification title (≤ 80 chars after truncation). Required."
56 },
57 "body": {
58 "type": "string",
59 "description": "Optional longer body (≤ 200 chars after truncation)."
60 }
61 },
62 "required": ["title"]
63 })
64 }
65
66 fn capabilities(&self) -> Vec<ToolCapability> {
67 // No filesystem or shell side effects; the only output is a single
68 // terminal-escape write to stdout. Mark as ReadOnly so the
69 // approval-requirement default is `Auto` and the tool routes
70 // through without prompting.
71 vec![ToolCapability::ReadOnly]
72 }
73
74 fn approval_requirement(&self) -> ApprovalRequirement {
75 ApprovalRequirement::Auto
76 }
77
78 async fn execute(&self, input: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> {
79 let title_raw = required_str(&input, "title")?;
80 let body_raw = optional_str(&input, "body")?.unwrap_or("");
81
82 // Char-bounded truncation (not byte-bounded) so we don't slice
83 // through a multi-byte sequence and emit invalid UTF-8 to the
84 // terminal.
85 let title: String = title_raw.chars().take(NOTIFY_TITLE_CAP).collect();
86 let body: String = body_raw.chars().take(NOTIFY_BODY_CAP).collect();
87 let title = title.trim();
88 let body = body.trim();
89
90 if title.is_empty() {
91 return Err(ToolError::execution_failed("title must not be empty"));
92 }
93
94 // #4834: model-authored text is the least trusted input that can
95 // reach Notification Center, so it goes through the typed payload
96 // like every other event kind — bounded, control-byte-stripped,
97 // and redacted for credentials, absolute paths, and raw tool JSON.
98 let payload = NotificationPayload::model_notify(
99 title,
100 if body.is_empty() { None } else { Some(body) },
101 );
102
103 let in_tmux = std::env::var("TMUX")
104 .map(|v| !v.is_empty())
105 .unwrap_or(false);
106
107 // Threshold = 0 so the notification always fires; the model has
108 // already decided this is the moment.
109 notify_done(
110 Method::Auto,
111 in_tmux,
112 &payload,
113 std::time::Duration::ZERO,
114 std::time::Duration::from_secs(1),
115 );
116
117 Ok(ToolResult::success(format!("notified: {title}")))
118 }
119 }
120
121 #[cfg(test)]
122 mod tests {
123 use super::*;
124 use std::path::Path;
125
126 fn ctx() -> ToolContext {
127 ToolContext::new(Path::new("."))
128 }
129
130 #[tokio::test]
131 async fn rejects_missing_title() {
132 let err = NotifyTool.execute(json!({}), &ctx()).await.unwrap_err();
133 assert!(err.to_string().to_lowercase().contains("title"), "{err}");
134 }
135
136 #[tokio::test]
137 async fn rejects_empty_title_after_trim() {
138 let err = NotifyTool
139 .execute(json!({"title": " "}), &ctx())
140 .await
141 .unwrap_err();
142 assert!(
143 err.to_string().to_lowercase().contains("must not be empty"),
144 "{err}"
145 );
146 }
147
148 #[tokio::test]
149 async fn truncates_title_to_cap() {
150 let long = "x".repeat(500);
151 let result = NotifyTool
152 .execute(json!({"title": long}), &ctx())
153 .await
154 .expect("ok");
155 // Confirmation message echoes the *truncated* title.
156 let echo_x_count = result.content.matches('x').count();
157 assert_eq!(echo_x_count, NOTIFY_TITLE_CAP);
158 }
159
160 #[tokio::test]
161 async fn accepts_body_optional() {
162 let result = NotifyTool
163 .execute(json!({"title": "done", "body": "tests pass"}), &ctx())
164 .await
165 .expect("ok");
166 assert!(result.success);
167 assert!(result.content.contains("done"));
168 }
169
170 #[tokio::test]
171 async fn safe_against_multibyte_truncation() {
172 // Construct a title whose char-count is below the cap but whose
173 // byte-count would be above a naive byte cap; assert no panic
174 // and the success-content roundtrips the title intact.
175 let title: String = "我".repeat(30); // 30 chars × 3 bytes = 90 bytes, < 80 chars cap (well, == 30 chars)
176 let result = NotifyTool
177 .execute(json!({"title": title.clone()}), &ctx())
178 .await
179 .expect("ok");
180 assert!(result.content.contains(&title));
181 }
182
183 #[test]
184 fn schema_exposes_title_and_body_fields() {
185 let schema = NotifyTool.input_schema();
186 let props = schema.get("properties").unwrap();
187 assert!(props.get("title").is_some());
188 assert!(props.get("body").is_some());
189 let required = schema.get("required").unwrap().as_array().unwrap();
190 assert!(required.iter().any(|v| v.as_str() == Some("title")));
191 assert!(!required.iter().any(|v| v.as_str() == Some("body")));
192 }
193 }
194
194 lines RUST