tirea_contract/runtime/tool_call/
suspension.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::io::decision_translation;
5
6/// Generic suspension request for client-side actions.
7#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
8pub struct Suspension {
9    /// Unique suspension ID.
10    #[serde(default)]
11    pub id: String,
12    /// Action identifier (freeform string, meaning defined by caller).
13    #[serde(default)]
14    pub action: String,
15    /// Human-readable message/description.
16    #[serde(default, skip_serializing_if = "String::is_empty")]
17    pub message: String,
18    /// Action-specific parameters.
19    #[serde(default, skip_serializing_if = "Value::is_null")]
20    pub parameters: Value,
21    /// Optional JSON Schema for expected response.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub response_schema: Option<Value>,
24}
25
26impl Suspension {
27    /// Create a new suspension with id and action.
28    pub fn new(id: impl Into<String>, action: impl Into<String>) -> Self {
29        Self {
30            id: id.into(),
31            action: action.into(),
32            message: String::new(),
33            parameters: Value::Null,
34            response_schema: None,
35        }
36    }
37
38    /// Set the message.
39    pub fn with_message(mut self, message: impl Into<String>) -> Self {
40        self.message = message.into();
41        self
42    }
43
44    /// Set the parameters.
45    pub fn with_parameters(mut self, parameters: Value) -> Self {
46        self.parameters = parameters;
47        self
48    }
49
50    /// Set the response schema.
51    pub fn with_response_schema(mut self, schema: Value) -> Self {
52        self.response_schema = Some(schema);
53        self
54    }
55}
56
57/// Generic suspension response.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SuspensionResponse {
60    /// The suspension target ID this response is for.
61    pub target_id: String,
62    /// Result value (structure defined by the action type).
63    pub result: Value,
64}
65
66impl SuspensionResponse {
67    fn deny_string_token(value: &str) -> bool {
68        decision_translation::is_denied_token(value)
69    }
70
71    fn object_deny_flag(obj: &serde_json::Map<String, Value>) -> bool {
72        [
73            "denied",
74            "reject",
75            "rejected",
76            "cancel",
77            "canceled",
78            "cancelled",
79            "abort",
80            "aborted",
81        ]
82        .iter()
83        .any(|key| obj.get(*key).and_then(Value::as_bool).unwrap_or(false))
84            || ["status", "decision", "action"].iter().any(|key| {
85                obj.get(*key)
86                    .and_then(Value::as_str)
87                    .map(decision_translation::is_denied_token)
88                    .unwrap_or(false)
89            })
90    }
91
92    /// Create a new suspension response.
93    pub fn new(target_id: impl Into<String>, result: Value) -> Self {
94        Self {
95            target_id: target_id.into(),
96            result,
97        }
98    }
99
100    /// Check if a result value indicates approval.
101    pub fn is_approved(result: &Value) -> bool {
102        match result {
103            Value::Bool(b) => *b,
104            Value::String(s) => {
105                let lower = s.to_lowercase();
106                matches!(
107                    lower.as_str(),
108                    "true" | "yes" | "approved" | "allow" | "confirm" | "ok" | "accept"
109                )
110            }
111            Value::Object(obj) => {
112                obj.get("approved")
113                    .and_then(|v| v.as_bool())
114                    .unwrap_or(false)
115                    || obj
116                        .get("allowed")
117                        .and_then(|v| v.as_bool())
118                        .unwrap_or(false)
119            }
120            _ => false,
121        }
122    }
123
124    /// Check if a result value indicates denial.
125    pub fn is_denied(result: &Value) -> bool {
126        match result {
127            Value::Bool(b) => !*b,
128            Value::String(s) => {
129                let lower = s.trim().to_lowercase();
130                Self::deny_string_token(&lower)
131            }
132            Value::Object(obj) => {
133                obj.get("approved")
134                    .and_then(|v| v.as_bool())
135                    .map(|v| !v)
136                    .unwrap_or(false)
137                    || Self::object_deny_flag(obj)
138            }
139            _ => false,
140        }
141    }
142
143    /// Check if this response indicates approval.
144    pub fn approved(&self) -> bool {
145        Self::is_approved(&self.result)
146    }
147
148    /// Check if this response indicates denial.
149    pub fn denied(&self) -> bool {
150        Self::is_denied(&self.result)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::SuspensionResponse;
157    use serde_json::json;
158
159    #[test]
160    fn suspension_response_treats_cancel_variants_as_denied() {
161        let denied_cases = [
162            json!("cancelled"),
163            json!("canceled"),
164            json!({"status":"cancelled"}),
165            json!({"decision":"abort"}),
166            json!({"canceled": true}),
167            json!({"cancelled": true}),
168        ];
169        for case in denied_cases {
170            assert!(
171                SuspensionResponse::is_denied(&case),
172                "expected denied for case: {case}"
173            );
174        }
175    }
176}