tirea_contract/io/
decision_translation.rs

1//! Shared decision-translation helpers for converting [`SuspensionResponse`]
2//! values into [`ToolCallDecision`] commands.
3//!
4//! Both the AG-UI and AI-SDK protocol adapters delegate to these functions so
5//! that approval/denial semantics are defined in exactly one place.
6
7use serde_json::Value;
8
9use crate::io::decision::ToolCallDecision;
10use crate::runtime::tool_call::lifecycle::{ResumeDecisionAction, ToolCallResume};
11use crate::runtime::tool_call::suspension::SuspensionResponse;
12
13/// Convert a [`SuspensionResponse`] into a routable [`ToolCallDecision`].
14pub fn suspension_response_to_decision(response: SuspensionResponse) -> ToolCallDecision {
15    let action = decision_action_from_result(&response.result);
16    let reason = if matches!(action, ResumeDecisionAction::Cancel) {
17        decision_reason_from_result(&response.result)
18    } else {
19        None
20    };
21    ToolCallDecision {
22        target_id: response.target_id.clone(),
23        resume: ToolCallResume {
24            decision_id: format!("decision_{}", response.target_id),
25            action,
26            result: response.result,
27            reason,
28            updated_at: current_unix_millis(),
29        },
30    }
31}
32
33/// Determine whether a result value represents approval or denial.
34///
35/// - `Bool(true)` → Resume, `Bool(false)` → Cancel
36/// - `String` → Resume unless it is a denial token (e.g. "deny", "cancel")
37/// - `Object` → Cancel when `approved: false`, a boolean denial flag is set,
38///   or a status/decision/action field contains a denial token
39/// - **All other types** (`Null`, `Array`, `Number`) → **Cancel** (safe default)
40pub fn decision_action_from_result(result: &Value) -> ResumeDecisionAction {
41    match result {
42        Value::Bool(approved) => {
43            if *approved {
44                ResumeDecisionAction::Resume
45            } else {
46                ResumeDecisionAction::Cancel
47            }
48        }
49        Value::String(value) => {
50            if is_denied_token(value) {
51                ResumeDecisionAction::Cancel
52            } else {
53                ResumeDecisionAction::Resume
54            }
55        }
56        Value::Object(obj) => {
57            if obj
58                .get("approved")
59                .and_then(Value::as_bool)
60                .map(|approved| !approved)
61                .unwrap_or(false)
62            {
63                return ResumeDecisionAction::Cancel;
64            }
65            if [
66                "denied",
67                "reject",
68                "rejected",
69                "cancel",
70                "canceled",
71                "cancelled",
72                "abort",
73                "aborted",
74            ]
75            .iter()
76            .any(|key| obj.get(*key).and_then(Value::as_bool).unwrap_or(false))
77            {
78                return ResumeDecisionAction::Cancel;
79            }
80            if ["status", "decision", "action"].iter().any(|key| {
81                obj.get(*key)
82                    .and_then(Value::as_str)
83                    .map(is_denied_token)
84                    .unwrap_or(false)
85            }) {
86                return ResumeDecisionAction::Cancel;
87            }
88            ResumeDecisionAction::Resume
89        }
90        // Null, Array, Number → default to Cancel (safe: reject unknown types).
91        _ => ResumeDecisionAction::Cancel,
92    }
93}
94
95/// Extract a human-readable reason string from a denial result.
96pub fn decision_reason_from_result(result: &Value) -> Option<String> {
97    match result {
98        Value::String(text) => {
99            if text.trim().is_empty() {
100                None
101            } else {
102                Some(text.to_string())
103            }
104        }
105        Value::Object(obj) => obj
106            .get("reason")
107            .and_then(Value::as_str)
108            .or_else(|| obj.get("message").and_then(Value::as_str))
109            .or_else(|| obj.get("error").and_then(Value::as_str))
110            .map(str::to_string),
111        _ => None,
112    }
113}
114
115/// Return `true` if a string value represents a denial intent.
116pub fn is_denied_token(value: &str) -> bool {
117    matches!(
118        value.trim().to_ascii_lowercase().as_str(),
119        "false"
120            | "no"
121            | "denied"
122            | "deny"
123            | "reject"
124            | "rejected"
125            | "cancel"
126            | "canceled"
127            | "cancelled"
128            | "abort"
129            | "aborted"
130    )
131}
132
133/// Current time as milliseconds since the Unix epoch.
134pub fn current_unix_millis() -> u64 {
135    std::time::SystemTime::now()
136        .duration_since(std::time::UNIX_EPOCH)
137        .map_or(0, |d| d.as_millis().min(u128::from(u64::MAX)) as u64)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use serde_json::json;
144
145    #[test]
146    fn bool_true_resumes() {
147        assert!(matches!(
148            decision_action_from_result(&json!(true)),
149            ResumeDecisionAction::Resume
150        ));
151    }
152
153    #[test]
154    fn bool_false_cancels() {
155        assert!(matches!(
156            decision_action_from_result(&json!(false)),
157            ResumeDecisionAction::Cancel
158        ));
159    }
160
161    #[test]
162    fn denied_tokens_cancel() {
163        for token in &[
164            "deny",
165            "denied",
166            "reject",
167            "rejected",
168            "cancel",
169            "canceled",
170            "cancelled",
171            "abort",
172            "aborted",
173            "no",
174            "false",
175        ] {
176            assert!(
177                matches!(
178                    decision_action_from_result(&json!(token)),
179                    ResumeDecisionAction::Cancel
180                ),
181                "expected Cancel for token: {token}"
182            );
183        }
184    }
185
186    #[test]
187    fn approval_string_resumes() {
188        assert!(matches!(
189            decision_action_from_result(&json!("yes")),
190            ResumeDecisionAction::Resume
191        ));
192    }
193
194    #[test]
195    fn null_array_number_cancel() {
196        for value in &[json!(null), json!([1, 2]), json!(42)] {
197            assert!(
198                matches!(
199                    decision_action_from_result(value),
200                    ResumeDecisionAction::Cancel
201                ),
202                "expected Cancel for value: {value}"
203            );
204        }
205    }
206
207    #[test]
208    fn object_approved_false_cancels() {
209        assert!(matches!(
210            decision_action_from_result(&json!({"approved": false})),
211            ResumeDecisionAction::Cancel
212        ));
213    }
214
215    #[test]
216    fn object_boolean_denial_flags() {
217        for key in [
218            "denied",
219            "reject",
220            "rejected",
221            "cancel",
222            "canceled",
223            "cancelled",
224            "abort",
225            "aborted",
226        ] {
227            let val = json!({ key: true });
228            assert!(
229                matches!(
230                    decision_action_from_result(&val),
231                    ResumeDecisionAction::Cancel
232                ),
233                "expected Cancel for key: {key}"
234            );
235        }
236    }
237
238    #[test]
239    fn object_status_field_denied() {
240        assert!(matches!(
241            decision_action_from_result(&json!({"status": "cancelled"})),
242            ResumeDecisionAction::Cancel
243        ));
244    }
245
246    #[test]
247    fn reason_extraction() {
248        assert_eq!(
249            decision_reason_from_result(&json!("not allowed")),
250            Some("not allowed".to_string())
251        );
252        assert_eq!(
253            decision_reason_from_result(&json!({"reason": "policy violation"})),
254            Some("policy violation".to_string())
255        );
256        assert_eq!(decision_reason_from_result(&json!(null)), None);
257    }
258
259    #[test]
260    fn suspension_response_converts_to_decision() {
261        let response = SuspensionResponse::new("fc_1", json!(true));
262        let decision = suspension_response_to_decision(response);
263        assert_eq!(decision.target_id, "fc_1");
264        assert!(matches!(
265            decision.resume.action,
266            ResumeDecisionAction::Resume
267        ));
268    }
269}