tirea_contract/io/
decision.rs

1pub use crate::runtime::tool_call::lifecycle::ResumeDecisionAction;
2use crate::runtime::tool_call::lifecycle::ToolCallResume;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// External decision command routed to a suspended tool call.
7///
8/// `target_id` may refer to:
9/// - suspended `call_id`
10/// - suspension id
11/// - pending external tool-call id
12///
13/// The resume payload (decision_id, action, result, reason, updated_at) is
14/// shared with `ToolCallResume` via `#[serde(flatten)]`.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct ToolCallDecision {
17    /// External target identifier used to resolve suspended call.
18    pub target_id: String,
19    /// Resume payload shared with `ToolCallResume`.
20    #[serde(flatten)]
21    pub resume: ToolCallResume,
22}
23
24impl ToolCallDecision {
25    /// Build an explicit resume decision.
26    pub fn resume(target_id: impl Into<String>, result: Value, updated_at: u64) -> Self {
27        Self {
28            target_id: target_id.into(),
29            resume: ToolCallResume {
30                decision_id: String::new(),
31                action: ResumeDecisionAction::Resume,
32                result,
33                reason: None,
34                updated_at,
35            },
36        }
37    }
38
39    /// Build an explicit cancel decision.
40    pub fn cancel(
41        target_id: impl Into<String>,
42        result: Value,
43        reason: Option<String>,
44        updated_at: u64,
45    ) -> Self {
46        Self {
47            target_id: target_id.into(),
48            resume: ToolCallResume {
49                decision_id: String::new(),
50                action: ResumeDecisionAction::Cancel,
51                result,
52                reason,
53                updated_at,
54            },
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn tool_call_decision_resume_constructor_sets_resume_action() {
65        let mut decision = ToolCallDecision::resume("fc_1", Value::Bool(true), 123);
66        decision.resume.decision_id = "decision_fc_1".to_string();
67        assert_eq!(decision.target_id, "fc_1");
68        assert_eq!(decision.resume.decision_id, "decision_fc_1");
69        assert!(matches!(
70            decision.resume.action,
71            ResumeDecisionAction::Resume
72        ));
73        assert_eq!(decision.resume.result, Value::Bool(true));
74        assert!(decision.resume.reason.is_none());
75        assert_eq!(decision.resume.updated_at, 123);
76    }
77
78    #[test]
79    fn tool_call_decision_cancel_constructor_sets_cancel_action() {
80        let mut decision = ToolCallDecision::cancel(
81            "fc_2",
82            serde_json::json!({
83                "approved": false,
84                "reason": "denied by user"
85            }),
86            Some("denied by user".to_string()),
87            456,
88        );
89        decision.resume.decision_id = "decision_fc_2".to_string();
90        assert!(matches!(
91            decision.resume.action,
92            ResumeDecisionAction::Cancel
93        ));
94        assert_eq!(decision.resume.reason.as_deref(), Some("denied by user"));
95        assert_eq!(decision.resume.updated_at, 456);
96    }
97
98    #[test]
99    fn tool_call_decision_serde_flatten_roundtrip() {
100        let decision = ToolCallDecision::resume("fc_1", Value::Bool(true), 42);
101        let json = serde_json::to_value(&decision).unwrap();
102
103        // Flattened: no "resume" key, fields at top level
104        assert!(json.get("resume").is_none(), "resume should be flattened");
105        assert_eq!(json["target_id"], "fc_1");
106        assert_eq!(json["action"], "resume");
107        assert_eq!(json["result"], true);
108
109        // Roundtrip
110        let deserialized: ToolCallDecision = serde_json::from_value(json).unwrap();
111        assert_eq!(deserialized, decision);
112    }
113}