Skip to main content

tirea_contract/event/
interaction.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4/// Generic interaction request for client-side actions.
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub struct Interaction {
7    /// Unique interaction ID.
8    pub id: String,
9    /// Action identifier (freeform string, meaning defined by caller).
10    pub action: String,
11    /// Human-readable message/description.
12    #[serde(default, skip_serializing_if = "String::is_empty")]
13    pub message: String,
14    /// Action-specific parameters.
15    #[serde(default, skip_serializing_if = "Value::is_null")]
16    pub parameters: Value,
17    /// Optional JSON Schema for expected response.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub response_schema: Option<Value>,
20}
21
22impl Interaction {
23    /// Create a new interaction with id and action.
24    pub fn new(id: impl Into<String>, action: impl Into<String>) -> Self {
25        Self {
26            id: id.into(),
27            action: action.into(),
28            message: String::new(),
29            parameters: Value::Null,
30            response_schema: None,
31        }
32    }
33
34    /// Set the message.
35    pub fn with_message(mut self, message: impl Into<String>) -> Self {
36        self.message = message.into();
37        self
38    }
39
40    /// Set the parameters.
41    pub fn with_parameters(mut self, parameters: Value) -> Self {
42        self.parameters = parameters;
43        self
44    }
45
46    /// Set the response schema.
47    pub fn with_response_schema(mut self, schema: Value) -> Self {
48        self.response_schema = Some(schema);
49        self
50    }
51}
52
53/// Generic interaction response.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct InteractionResponse {
56    /// The interaction ID this response is for.
57    pub interaction_id: String,
58    /// Result value (structure defined by the action type).
59    pub result: Value,
60}
61
62impl InteractionResponse {
63    /// Create a new interaction response.
64    pub fn new(interaction_id: impl Into<String>, result: Value) -> Self {
65        Self {
66            interaction_id: interaction_id.into(),
67            result,
68        }
69    }
70
71    /// Check if a result value indicates approval.
72    pub fn is_approved(result: &Value) -> bool {
73        match result {
74            Value::Bool(b) => *b,
75            Value::String(s) => {
76                let lower = s.to_lowercase();
77                matches!(
78                    lower.as_str(),
79                    "true" | "yes" | "approved" | "allow" | "confirm" | "ok" | "accept"
80                )
81            }
82            Value::Object(obj) => {
83                obj.get("approved")
84                    .and_then(|v| v.as_bool())
85                    .unwrap_or(false)
86                    || obj
87                        .get("allowed")
88                        .and_then(|v| v.as_bool())
89                        .unwrap_or(false)
90            }
91            _ => false,
92        }
93    }
94
95    /// Check if a result value indicates denial.
96    pub fn is_denied(result: &Value) -> bool {
97        match result {
98            Value::Bool(b) => !*b,
99            Value::String(s) => {
100                let lower = s.to_lowercase();
101                matches!(
102                    lower.as_str(),
103                    "false" | "no" | "denied" | "deny" | "reject" | "cancel" | "abort"
104                )
105            }
106            Value::Object(obj) => {
107                obj.get("approved")
108                    .and_then(|v| v.as_bool())
109                    .map(|v| !v)
110                    .unwrap_or(false)
111                    || obj.get("denied").and_then(|v| v.as_bool()).unwrap_or(false)
112            }
113            _ => false,
114        }
115    }
116
117    /// Check if this response indicates approval.
118    pub fn approved(&self) -> bool {
119        Self::is_approved(&self.result)
120    }
121
122    /// Check if this response indicates denial.
123    pub fn denied(&self) -> bool {
124        Self::is_denied(&self.result)
125    }
126}
127
128// ============================================================================
129// Frontend Tool Invocation (first-class citizen model)
130// ============================================================================
131
132/// A frontend tool invocation record persisted to thread state.
133///
134/// Replaces the `Interaction` struct for frontend tool call tracking. Each
135/// invocation captures the frontend tool being called, its origin context
136/// (which plugin/tool triggered it), and the routing strategy for handling
137/// the frontend's response.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct FrontendToolInvocation {
140    /// Unique ID for this frontend tool call (sent to frontend as `toolCallId`).
141    pub call_id: String,
142    /// Frontend tool name (e.g. "copyToClipboard", "PermissionConfirm").
143    pub tool_name: String,
144    /// Frontend tool arguments.
145    #[serde(default, skip_serializing_if = "Value::is_null")]
146    pub arguments: Value,
147    /// Where this invocation originated from.
148    pub origin: InvocationOrigin,
149    /// How to route the frontend's response.
150    pub routing: ResponseRouting,
151}
152
153impl FrontendToolInvocation {
154    /// Create a new frontend tool invocation.
155    pub fn new(
156        call_id: impl Into<String>,
157        tool_name: impl Into<String>,
158        arguments: Value,
159        origin: InvocationOrigin,
160        routing: ResponseRouting,
161    ) -> Self {
162        Self {
163            call_id: call_id.into(),
164            tool_name: tool_name.into(),
165            arguments,
166            origin,
167            routing,
168        }
169    }
170
171    /// Convert to an `Interaction` for backward compatibility with the
172    /// existing event system during the transition period.
173    pub fn to_interaction(&self) -> Interaction {
174        Interaction::new(&self.call_id, format!("tool:{}", self.tool_name))
175            .with_parameters(self.arguments.clone())
176    }
177}
178
179/// Where a frontend tool invocation originated from.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(tag = "type", rename_all = "snake_case")]
182pub enum InvocationOrigin {
183    /// A backend tool call was intercepted (e.g. permission check).
184    ToolCallIntercepted {
185        /// The intercepted backend tool call ID.
186        backend_call_id: String,
187        /// The intercepted backend tool name.
188        backend_tool_name: String,
189        /// The intercepted backend tool arguments.
190        backend_arguments: Value,
191    },
192    /// A plugin directly initiated the frontend tool call (no backend tool context).
193    PluginInitiated {
194        /// The plugin that initiated this call.
195        plugin_id: String,
196    },
197}
198
199/// How to route the frontend's response after it completes execution.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[serde(from = "ResponseRoutingWire", into = "ResponseRoutingWire")]
202pub enum ResponseRouting {
203    /// Replay the original backend tool.
204    ///
205    /// Used for permission prompts where approval allows replaying the
206    /// intercepted backend tool call.
207    ReplayOriginalTool,
208    /// The frontend result IS the tool result — inject it directly into
209    /// the LLM message history as the tool call response.
210    /// Used for direct frontend tools (e.g. copyToClipboard).
211    UseAsToolResult,
212    /// Pass the frontend result to the LLM as an independent message.
213    PassToLLM,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217#[serde(tag = "strategy", rename_all = "snake_case")]
218enum ResponseRoutingWire {
219    /// Legacy shape accepted for backward compatibility.
220    ReplayOriginalTool {
221        #[serde(default, skip_serializing_if = "Vec::is_empty")]
222        state_patches: Vec<Value>,
223    },
224    /// The frontend result IS the tool result — inject it directly into
225    /// the LLM message history as the tool call response.
226    /// Used for direct frontend tools (e.g. copyToClipboard).
227    UseAsToolResult,
228    /// Pass the frontend result to the LLM as an independent message.
229    PassToLLM,
230}
231
232impl From<ResponseRoutingWire> for ResponseRouting {
233    fn from(value: ResponseRoutingWire) -> Self {
234        match value {
235            ResponseRoutingWire::ReplayOriginalTool { .. } => Self::ReplayOriginalTool,
236            ResponseRoutingWire::UseAsToolResult => Self::UseAsToolResult,
237            ResponseRoutingWire::PassToLLM => Self::PassToLLM,
238        }
239    }
240}
241
242impl From<ResponseRouting> for ResponseRoutingWire {
243    fn from(value: ResponseRouting) -> Self {
244        match value {
245            ResponseRouting::ReplayOriginalTool => Self::ReplayOriginalTool {
246                state_patches: Vec::new(),
247            },
248            ResponseRouting::UseAsToolResult => Self::UseAsToolResult,
249            ResponseRouting::PassToLLM => Self::PassToLLM,
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::ResponseRouting;
257    use serde_json::json;
258
259    #[test]
260    fn replay_original_tool_serializes_without_state_patches() {
261        let value =
262            serde_json::to_value(ResponseRouting::ReplayOriginalTool).expect("serialize routing");
263        assert_eq!(value, json!({ "strategy": "replay_original_tool" }));
264    }
265
266    #[test]
267    fn replay_original_tool_deserializes_legacy_state_patches_shape() {
268        let value = json!({
269            "strategy": "replay_original_tool",
270            "state_patches": [{
271                "op": "set",
272                "path": ["permissions", "approved_calls", "call_1"],
273                "value": true
274            }]
275        });
276        let routing: ResponseRouting =
277            serde_json::from_value(value).expect("deserialize legacy replay routing");
278        assert_eq!(routing, ResponseRouting::ReplayOriginalTool);
279    }
280}