mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 13:27:11 +00:00
160 lines
5.9 KiB
Rust
160 lines
5.9 KiB
Rust
//! A partner-owned tool crate: no dependency on the OpenWorker agent loop.
|
|
use openworker_tools::{
|
|
Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput, ToolRegistry,
|
|
};
|
|
use serde_json::{json, Value};
|
|
|
|
pub struct NotifyUser;
|
|
impl Tool for NotifyUser {
|
|
fn descriptor(&self) -> ToolDescriptor {
|
|
ToolDescriptor {
|
|
qualified_name: "notifications.notify_user".into(),
|
|
description:
|
|
"Emit a notification for the application UI. Does not send an external message."
|
|
.into(),
|
|
input_schema: schema(),
|
|
version: "1".into(),
|
|
}
|
|
}
|
|
fn call(&self, context: ToolContext, arguments: Value) -> ToolFuture<'_> {
|
|
Box::pin(async move {
|
|
let (title, message) = fields(&arguments)?;
|
|
context
|
|
.progress(
|
|
"App notification",
|
|
json!({"kind":"user_notification", "title":title,"message":message}),
|
|
)
|
|
.await?;
|
|
Ok(ToolOutput::json(
|
|
json!({"delivery":"app_event","queued":true,"title":title,"message":message}),
|
|
))
|
|
})
|
|
}
|
|
}
|
|
pub fn register(registry: &mut ToolRegistry) -> anyhow::Result<()> {
|
|
registry.register(NotifyUser)
|
|
}
|
|
fn schema() -> Value {
|
|
json!({"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":120},"message":{"type":"string","minLength":1,"maxLength":1000}},"required":["title","message"],"additionalProperties":false})
|
|
}
|
|
fn fields(arguments: &Value) -> Result<(&str, &str), ToolError> {
|
|
let title = arguments["title"].as_str().unwrap_or_default();
|
|
let message = arguments["message"].as_str().unwrap_or_default();
|
|
if title.trim().is_empty()
|
|
|| title.len() > 480
|
|
|| message.trim().is_empty()
|
|
|| message.len() > 4000
|
|
{
|
|
return Err(ToolError::new(
|
|
"invalid_arguments",
|
|
"Title and message must be nonempty bounded strings",
|
|
));
|
|
}
|
|
Ok((title, message))
|
|
}
|
|
|
|
/// Explicit native UI demo, not used by the Linux application runtime. Calling this
|
|
/// directly does not supply harness approvals; the embedding app must authorize it.
|
|
#[cfg(target_os = "macos")]
|
|
pub struct MacDialog;
|
|
#[cfg(target_os = "macos")]
|
|
impl Tool for MacDialog {
|
|
fn descriptor(&self) -> ToolDescriptor {
|
|
NotifyUser.descriptor()
|
|
}
|
|
fn call(&self, context: ToolContext, arguments: Value) -> ToolFuture<'_> {
|
|
Box::pin(async move {
|
|
let (title, message) = fields(&arguments)?;
|
|
// Fixed script. Untrusted strings are argv values, never AppleScript.
|
|
let script = "on run argv\nset r to display dialog (item 2 of argv) with title (item 1 of argv) buttons {\"OK\"} default button \"OK\" giving up after 5\nreturn gave up of r\nend run";
|
|
let mut command = tokio::process::Command::new("/usr/bin/osascript");
|
|
command
|
|
.args(["-e", script, "--", title, message])
|
|
.kill_on_drop(true);
|
|
let result = tokio::select! { biased;
|
|
_ = context.cancellation.cancelled() => return Err(ToolError::cancelled()),
|
|
result = tokio::time::timeout(std::time::Duration::from_secs(10), command.output()) => result,
|
|
};
|
|
let output = result
|
|
.map_err(|_| {
|
|
ToolError::new(
|
|
"timeout",
|
|
"Dialog status unknown after timeout; do not automatically retry",
|
|
)
|
|
})?
|
|
.map_err(|_| ToolError::new("launch_failed", "Could not launch macOS dialog"))?;
|
|
if !output.status.success() {
|
|
return Err(ToolError::new(
|
|
"dialog_failed",
|
|
"macOS dialog did not complete successfully",
|
|
));
|
|
}
|
|
let timed_out = match String::from_utf8_lossy(&output.stdout).trim() {
|
|
"true" => true,
|
|
"false" => false,
|
|
_ => {
|
|
return Err(ToolError::new(
|
|
"unknown_status",
|
|
"Dialog returned an unrecognized status; do not automatically retry",
|
|
))
|
|
}
|
|
};
|
|
Ok(ToolOutput::json(
|
|
json!({"delivery":"macos_dialog","acknowledged":!timed_out,"auto_dismissed":timed_out}),
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[tokio::test]
|
|
async fn notification_has_stable_identity_and_structured_progress() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(2);
|
|
let context = ToolContext::new(
|
|
"s".into(),
|
|
"t".into(),
|
|
"c".into(),
|
|
std::env::temp_dir(),
|
|
Default::default(),
|
|
tx,
|
|
);
|
|
let output = NotifyUser
|
|
.call(
|
|
context,
|
|
json!({"title":"Ready","message":"Review complete"}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(output.data["delivery"], "app_event");
|
|
assert_eq!(rx.recv().await.unwrap().data["kind"], "user_notification");
|
|
}
|
|
#[tokio::test]
|
|
async fn cancellation_prevents_notification() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(2);
|
|
let token = openworker_tools::CancellationToken::new();
|
|
token.cancel();
|
|
let context = ToolContext::new(
|
|
"s".into(),
|
|
"t".into(),
|
|
"c".into(),
|
|
std::env::temp_dir(),
|
|
token,
|
|
tx,
|
|
);
|
|
assert_eq!(
|
|
NotifyUser
|
|
.call(
|
|
context,
|
|
json!({"title":"Ready","message":"Review complete"})
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
.code,
|
|
"cancelled"
|
|
);
|
|
assert!(rx.recv().await.is_none());
|
|
}
|
|
}
|