tirea_contract/io/
decision_translation.rs1use 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
13pub 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
33pub 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 _ => ResumeDecisionAction::Cancel,
92 }
93}
94
95pub 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
115pub 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
133pub 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}