use async_trait::async_trait;
use serde_json::{Value, json};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, required_str,
};
use crate::tui::notifications::{Method, NotificationPayload, notify_done};
const NOTIFY_TITLE_CAP: usize = 80;
const NOTIFY_BODY_CAP: usize = 200;
pub struct NotifyTool;
#[async_trait]
impl ToolSpec for NotifyTool {
fn name(&self) -> &'static str {
"notify"
}
fn description(&self) -> &'static str {
"Send a desktop notification only when the user must act: a long task \
completed, a blocking error needs a decision, or progress cannot \
continue without an answer. Never notify for routine progress, \
acknowledgements, or liveness. Pass a short `title` and optional \
`body`. Users can silence everything with \
`[notifications].method = \"off\"` or `[notifications].quiet = true`, \
or this category with `[notifications.events].model-notify = false`; \
disabled notifications are silent no-ops."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short notification title (≤ 80 chars after truncation). Required."
},
"body": {
"type": "string",
"description": "Optional longer body (≤ 200 chars after truncation)."
}
},
"required": ["title"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> {
let title_raw = required_str(&input, "title")?;
let body_raw = optional_str(&input, "body")?.unwrap_or("");
let title: String = title_raw.chars().take(NOTIFY_TITLE_CAP).collect();
let body: String = body_raw.chars().take(NOTIFY_BODY_CAP).collect();
let title = title.trim();
let body = body.trim();
if title.is_empty() {
return Err(ToolError::execution_failed("title must not be empty"));
}
let payload = NotificationPayload::model_notify(
title,
if body.is_empty() { None } else { Some(body) },
);
let in_tmux = std::env::var("TMUX")
.map(|v| !v.is_empty())
.unwrap_or(false);
notify_done(
Method::Auto,
in_tmux,
&payload,
std::time::Duration::ZERO,
std::time::Duration::from_secs(1),
);
Ok(ToolResult::success(format!("notified: {title}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn ctx() -> ToolContext {
ToolContext::new(Path::new("."))
}
#[tokio::test]
async fn rejects_missing_title() {
let err = NotifyTool.execute(json!({}), &ctx()).await.unwrap_err();
assert!(err.to_string().to_lowercase().contains("title"), "{err}");
}
#[tokio::test]
async fn rejects_empty_title_after_trim() {
let err = NotifyTool
.execute(json!({"title": " "}), &ctx())
.await
.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("must not be empty"),
"{err}"
);
}
#[tokio::test]
async fn truncates_title_to_cap() {
let long = "x".repeat(500);
let result = NotifyTool
.execute(json!({"title": long}), &ctx())
.await
.expect("ok");
let echo_x_count = result.content.matches('x').count();
assert_eq!(echo_x_count, NOTIFY_TITLE_CAP);
}
#[tokio::test]
async fn accepts_body_optional() {
let result = NotifyTool
.execute(json!({"title": "done", "body": "tests pass"}), &ctx())
.await
.expect("ok");
assert!(result.success);
assert!(result.content.contains("done"));
}
#[tokio::test]
async fn safe_against_multibyte_truncation() {
let title: String = "我".repeat(30); let result = NotifyTool
.execute(json!({"title": title.clone()}), &ctx())
.await
.expect("ok");
assert!(result.content.contains(&title));
}
#[test]
fn schema_exposes_title_and_body_fields() {
let schema = NotifyTool.input_schema();
let props = schema.get("properties").unwrap();
assert!(props.get("title").is_some());
assert!(props.get("body").is_some());
let required = schema.get("required").unwrap().as_array().unwrap();
assert!(required.iter().any(|v| v.as_str() == Some("title")));
assert!(!required.iter().any(|v| v.as_str() == Some("body")));
}
}