返回 CodeWhale
notify.rs
根目录 / crates / tui / src / tools / notify.rs
1 //! `notify` tool — model-callable desktop notification (#1322).
2 //!
3 //! Delivered by the terminal host through `crate::host_terminal` (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 notification gate. 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
23 /// Maximum chars passed through for the title — keeps the OSC 9 escape
24 /// reasonable on terminals that wrap long titles awkwardly.
25 const NOTIFY_TITLE_CAP: usize = 80;
26 /// Maximum chars passed through for the body. Most receivers truncate
27 /// past ~120, so 200 leaves headroom while still bounded.
28 const NOTIFY_BODY_CAP: usize = 200;
29
30 /// Tool that fires a single desktop notification.
31 pub struct NotifyTool;
32
33 #[async_trait]
34 impl ToolSpec for NotifyTool {
35 fn name(&self) -> &'static str {
36 "notify"
37 }
38
39 fn description(&self) -> &'static str {
40 "Send a desktop notification only when the user must act: a long task \
41 completed, a blocking error needs a decision, or progress cannot \
42 continue without an answer. Never notify for routine progress, \
43 acknowledgements, or liveness. Pass a short `title` and optional \
44 `body`. Users can silence everything with \
45 `[notifications].method = \"off\"` or `[notifications].quiet = true`, \
46 or this category with `[notifications.events].model-notify = false`; \
47 disabled notifications are silent no-ops."
48 }
49
50 fn input_schema(&self) -> Value {
51 json!({
52 "type": "object",
53 "properties": {
54 "title": {
55 "type": "string",
56 "description": "Short notification title (≤ 80 chars after truncation). Required."
57 },
58 "body": {
59 "type": "string",
60 "description": "Optional longer body (≤ 200 chars after truncation)."
61 }
62 },
63 "required": ["title"]
64 })
65 }
66
67 fn capabilities(&self) -> Vec<ToolCapability> {
68 // No filesystem or shell side effects; the only output is one
69 // notification the terminal host delivers (or declines to, when
70 // none is installed). 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: the host builds the typed payload from this text (bounded,
97 // control-byte-stripped, redacted) and applies the user's method,
98 // gate and attention policy. Threshold zero: the model has already
99 // decided this is the moment.
100 let receipt = crate::host_terminal::host()
101 .notify_model(title, if body.is_empty() { None } else { Some(body) });
102
103 Ok(ToolResult::success(format!("{receipt}: {title}")))
104 }
105 }
106
107 #[cfg(test)]
108 mod tests {
109 use super::*;
110 use std::path::Path;
111
112 fn ctx() -> ToolContext {
113 ToolContext::new(Path::new("."))
114 }
115
116 #[tokio::test]
117 async fn rejects_missing_title() {
118 let err = NotifyTool.execute(json!({}), &ctx()).await.unwrap_err();
119 assert!(err.to_string().to_lowercase().contains("title"), "{err}");
120 }
121
122 #[tokio::test]
123 async fn rejects_empty_title_after_trim() {
124 let err = NotifyTool
125 .execute(json!({"title": " "}), &ctx())
126 .await
127 .unwrap_err();
128 assert!(
129 err.to_string().to_lowercase().contains("must not be empty"),
130 "{err}"
131 );
132 }
133
134 #[tokio::test]
135 async fn truncates_title_to_cap() {
136 let long = "x".repeat(500);
137 let result = NotifyTool
138 .execute(json!({"title": long}), &ctx())
139 .await
140 .expect("ok");
141 // Confirmation message echoes the *truncated* title.
142 let echo_x_count = result.content.matches('x').count();
143 assert_eq!(echo_x_count, NOTIFY_TITLE_CAP);
144 }
145
146 #[tokio::test]
147 async fn accepts_body_optional() {
148 let result = NotifyTool
149 .execute(json!({"title": "done", "body": "tests pass"}), &ctx())
150 .await
151 .expect("ok");
152 assert!(result.success);
153 assert!(result.content.contains("done"));
154 }
155
156 #[tokio::test]
157 async fn safe_against_multibyte_truncation() {
158 // Construct a title whose char-count is below the cap but whose
159 // byte-count would be above a naive byte cap; assert no panic
160 // and the success-content roundtrips the title intact.
161 let title: String = "我".repeat(30); // 30 chars × 3 bytes = 90 bytes, < 80 chars cap (well, == 30 chars)
162 let result = NotifyTool
163 .execute(json!({"title": title.clone()}), &ctx())
164 .await
165 .expect("ok");
166 assert!(result.content.contains(&title));
167 }
168
169 #[test]
170 fn schema_exposes_title_and_body_fields() {
171 let schema = NotifyTool.input_schema();
172 let props = schema.get("properties").unwrap();
173 assert!(props.get("title").is_some());
174 assert!(props.get("body").is_some());
175 let required = schema.get("required").unwrap().as_array().unwrap();
176 assert!(required.iter().any(|v| v.as_str() == Some("title")));
177 assert!(!required.iter().any(|v| v.as_str() == Some("body")));
178 }
179
180 #[tokio::test]
181 async fn the_model_sees_success_whatever_the_host_did() {
182 // The description promises a *silent* no-op: whatever the host did
183 // with the notification, the model sees success (nothing to retry).
184 // No host is installed in this test binary, so this is the no-host
185 // receipt; `method = "off"` through the TUI host is covered by
186 // `tui::notifications` (`notify_model_honors_the_installed_off_method`)
187 // and `tui::ui::terminal` (`tui_host_routes_the_notify_tool_through_the_installed_method`).
188
189 let result = NotifyTool
190 .execute(json!({"title": "done"}), &ctx())
191 .await
192 .expect("ok");
193 assert!(result.success);
194 // The receipt is the installed host's, not a hard-coded "sent": no
195 // host is installed in this test binary, so it is the no-host one.
196 assert_eq!(
197 result.content,
198 "notification not sent: no terminal host: done"
199 );
200 }
201 }
202
202 lines RUST