Skip to main content

starpod_hooks/
output.rs

1//! Hook output types — the data returned by hook callbacks to control agent behavior.
2
3use crate::permissions::{PermissionDecision, PermissionUpdate};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Hook return value — either async (fire-and-forget) or sync (blocking).
8///
9/// # Example
10///
11/// ```
12/// use starpod_hooks::HookOutput;
13///
14/// // Default is a no-op sync output
15/// let output = HookOutput::default();
16/// assert!(matches!(output, HookOutput::Sync(_)));
17/// ```
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(untagged)]
20pub enum HookOutput {
21    Async(AsyncHookOutput),
22    Sync(SyncHookOutput),
23}
24
25impl Default for HookOutput {
26    fn default() -> Self {
27        HookOutput::Sync(SyncHookOutput::default())
28    }
29}
30
31/// Async hook output — the agent proceeds without waiting for the hook to finish.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AsyncHookOutput {
34    /// Must be true to signal async mode.
35    #[serde(rename = "async")]
36    pub is_async: bool,
37    /// Optional timeout in milliseconds for the background operation.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub async_timeout: Option<u64>,
40}
41
42/// Sync hook output — controls the agent's behavior.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct SyncHookOutput {
45    /// Whether the agent should continue running after this hook.
46    #[serde(rename = "continue", skip_serializing_if = "Option::is_none")]
47    pub should_continue: Option<bool>,
48
49    /// Suppress output from being shown.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub suppress_output: Option<bool>,
52
53    /// Reason for stopping.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub stop_reason: Option<String>,
56
57    /// Approve or block decision.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub decision: Option<HookDecision>,
60
61    /// Inject a system message into the conversation visible to the model.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub system_message: Option<String>,
64
65    /// Reason for the decision.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub reason: Option<String>,
68
69    /// Hook-specific output that controls the current operation.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub hook_specific_output: Option<HookSpecificOutput>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum HookDecision {
77    Approve,
78    Block,
79}
80
81/// Hook-specific output varies by hook event type.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(tag = "hookEventName")]
84pub enum HookSpecificOutput {
85    PreToolUse {
86        #[serde(skip_serializing_if = "Option::is_none")]
87        permission_decision: Option<PermissionDecision>,
88        #[serde(skip_serializing_if = "Option::is_none")]
89        permission_decision_reason: Option<String>,
90        #[serde(skip_serializing_if = "Option::is_none")]
91        updated_input: Option<HashMap<String, serde_json::Value>>,
92        #[serde(skip_serializing_if = "Option::is_none")]
93        additional_context: Option<String>,
94    },
95
96    PostToolUse {
97        #[serde(skip_serializing_if = "Option::is_none")]
98        additional_context: Option<String>,
99        #[serde(skip_serializing_if = "Option::is_none")]
100        updated_mcp_tool_output: Option<serde_json::Value>,
101    },
102
103    PostToolUseFailure {
104        #[serde(skip_serializing_if = "Option::is_none")]
105        additional_context: Option<String>,
106    },
107
108    UserPromptSubmit {
109        #[serde(skip_serializing_if = "Option::is_none")]
110        additional_context: Option<String>,
111    },
112
113    SessionStart {
114        #[serde(skip_serializing_if = "Option::is_none")]
115        additional_context: Option<String>,
116    },
117
118    Setup {
119        #[serde(skip_serializing_if = "Option::is_none")]
120        additional_context: Option<String>,
121    },
122
123    SubagentStart {
124        #[serde(skip_serializing_if = "Option::is_none")]
125        additional_context: Option<String>,
126    },
127
128    Notification {
129        #[serde(skip_serializing_if = "Option::is_none")]
130        additional_context: Option<String>,
131    },
132
133    PermissionRequest {
134        decision: PermissionRequestDecision,
135    },
136}
137
138/// Decision for PermissionRequest hook.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "behavior")]
141pub enum PermissionRequestDecision {
142    #[serde(rename = "allow")]
143    Allow {
144        #[serde(skip_serializing_if = "Option::is_none")]
145        updated_input: Option<HashMap<String, serde_json::Value>>,
146        #[serde(skip_serializing_if = "Option::is_none")]
147        updated_permissions: Option<Vec<PermissionUpdate>>,
148    },
149    #[serde(rename = "deny")]
150    Deny {
151        #[serde(skip_serializing_if = "Option::is_none")]
152        message: Option<String>,
153        #[serde(skip_serializing_if = "Option::is_none")]
154        interrupt: Option<bool>,
155    },
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn default_output_is_sync() {
164        let output = HookOutput::default();
165        assert!(matches!(output, HookOutput::Sync(_)));
166    }
167
168    #[test]
169    fn sync_output_default_has_no_fields_set() {
170        let sync = SyncHookOutput::default();
171        assert!(sync.should_continue.is_none());
172        assert!(sync.suppress_output.is_none());
173        assert!(sync.stop_reason.is_none());
174        assert!(sync.decision.is_none());
175        assert!(sync.system_message.is_none());
176        assert!(sync.reason.is_none());
177        assert!(sync.hook_specific_output.is_none());
178    }
179
180    #[test]
181    fn hook_decision_serde() {
182        let approve = HookDecision::Approve;
183        let json = serde_json::to_string(&approve).unwrap();
184        assert_eq!(json, "\"approve\"");
185
186        let block = HookDecision::Block;
187        let json = serde_json::to_string(&block).unwrap();
188        assert_eq!(json, "\"block\"");
189    }
190
191    #[test]
192    fn async_output_roundtrip() {
193        let output = HookOutput::Async(AsyncHookOutput {
194            is_async: true,
195            async_timeout: Some(5000),
196        });
197        let json = serde_json::to_string(&output).unwrap();
198        assert!(json.contains("\"async\":true"));
199        assert!(json.contains("5000"));
200    }
201
202    #[test]
203    fn sync_output_with_decision_roundtrip() {
204        let output = HookOutput::Sync(SyncHookOutput {
205            should_continue: Some(false),
206            decision: Some(HookDecision::Block),
207            reason: Some("blocked by policy".into()),
208            ..Default::default()
209        });
210        let json = serde_json::to_string(&output).unwrap();
211        let back: HookOutput = serde_json::from_str(&json).unwrap();
212        match back {
213            HookOutput::Sync(sync) => {
214                assert_eq!(sync.should_continue, Some(false));
215                assert_eq!(sync.decision, Some(HookDecision::Block));
216                assert_eq!(sync.reason.as_deref(), Some("blocked by policy"));
217            }
218            _ => panic!("expected Sync output"),
219        }
220    }
221
222    #[test]
223    fn pre_tool_use_specific_output() {
224        let specific = HookSpecificOutput::PreToolUse {
225            permission_decision: Some(PermissionDecision::Deny),
226            permission_decision_reason: Some("not allowed".into()),
227            updated_input: None,
228            additional_context: Some("context".into()),
229        };
230        let json = serde_json::to_string(&specific).unwrap();
231        assert!(json.contains("\"hookEventName\":\"PreToolUse\""));
232        assert!(json.contains("\"permission_decision\":\"deny\""));
233    }
234
235    #[test]
236    fn permission_request_decision_allow() {
237        let decision = PermissionRequestDecision::Allow {
238            updated_input: None,
239            updated_permissions: None,
240        };
241        let json = serde_json::to_string(&decision).unwrap();
242        assert!(json.contains("\"behavior\":\"allow\""));
243    }
244
245    #[test]
246    fn permission_request_decision_deny_with_message() {
247        let decision = PermissionRequestDecision::Deny {
248            message: Some("no access".into()),
249            interrupt: Some(true),
250        };
251        let json = serde_json::to_string(&decision).unwrap();
252        assert!(json.contains("\"behavior\":\"deny\""));
253        assert!(json.contains("no access"));
254    }
255}