Skip to main content

rpi_plugin_sdk/
dialogs.rs

1//! Transport-independent dialog semantics. Presentation kinds do not imply permission.
2use serde_json::Value;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct PermissionOption {
6    pub title: String,
7    pub permission: String,
8}
9
10/// Validate the explicit permission semantics and retain the plugin's answer labels.
11pub fn permission_options(ui: &Value) -> Result<Vec<PermissionOption>, String> {
12    if ui.get("purpose").and_then(Value::as_str) != Some("permission") {
13        return Err("dialog is not a permission request".to_owned());
14    }
15    if !matches!(
16        ui.get("kind").and_then(Value::as_str),
17        Some("confirm" | "selector")
18    ) || [
19        "allowMultiple",
20        "allow_multiple",
21        "multiple",
22        "allowFreeform",
23        "allow_freeform",
24        "allow_custom",
25    ]
26    .iter()
27    .any(|key| ui.get(*key).and_then(Value::as_bool) == Some(true))
28        || ui.get("questions").is_some()
29    {
30        return Err("permission dialogs require a single choice without freeform input".to_owned());
31    }
32    let options = ui
33        .get("options")
34        .and_then(Value::as_array)
35        .ok_or("permission dialog requires options")?;
36    let mut result: Vec<PermissionOption> = Vec::new();
37    for option in options {
38        let title = option
39            .get("title")
40            .and_then(Value::as_str)
41            .filter(|s| !s.is_empty() && s.trim() == *s)
42            .ok_or("permission option requires a nonempty title")?;
43        let permission = option
44            .get("permission")
45            .and_then(Value::as_str)
46            .filter(|s| {
47                matches!(
48                    *s,
49                    "allow_once" | "allow_always" | "reject_once" | "reject_always"
50                )
51            })
52            .ok_or("permission option requires an explicit allow/reject decision")?;
53        if result.iter().any(|choice| choice.title == title) {
54            return Err("permission option titles must be unique".to_owned());
55        }
56        result.push(PermissionOption {
57            title: title.to_owned(),
58            permission: permission.to_owned(),
59        });
60    }
61    if result.is_empty() {
62        return Err("permission dialog requires at least one option".to_owned());
63    }
64    Ok(result)
65}
66
67pub fn validate(ui: &Value, tool_call_id: Option<&str>) -> Result<(), String> {
68    match ui.get("purpose") {
69        None => Ok(()),
70        Some(Value::String(purpose)) if purpose == "question" => Ok(()),
71        Some(Value::String(purpose)) if purpose == "permission" => {
72            if tool_call_id.is_none_or(|id| id.trim().is_empty()) {
73                return Err("permission dialog requires a nonempty toolCallId".to_owned());
74            }
75            permission_options(ui).map(|_| ())
76        }
77        _ => Err("dialog purpose must be question or permission".to_owned()),
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use serde_json::json;
85
86    #[test]
87    fn selectors_are_questions_until_explicitly_declared_permissions() {
88        let ui = json!({"kind":"selector","options":[{"title":"Rust"},{"title":"Python"}]});
89        assert!(validate(&ui, None).is_ok());
90        assert!(permission_options(&ui).is_err());
91    }
92
93    #[test]
94    fn permission_options_preserve_labels_and_require_semantics() {
95        let mut ui = json!({"purpose":"permission","kind":"confirm","options":[
96            {"title":"允许一次","permission":"allow_once"},
97            {"title":"拒绝","permission":"reject_once"}
98        ]});
99        assert!(validate(&ui, None).is_err());
100        assert!(validate(&ui, Some("tool-1")).is_ok());
101        assert_eq!(permission_options(&ui).unwrap()[0].title, "允许一次");
102        ui["options"][1]["permission"] = json!("arbitrary");
103        assert!(validate(&ui, Some("tool-1")).is_err());
104    }
105}