Skip to main content

forge_sdk/
hooks.rs

1//! Hook callbacks - 10 hook kinds dispatched by opaque `callback_id`.
2//!
3//! SDK's `HookMatcher` / `HookContext` machinery. Callbacks
4//! are registered at initialize time; the CLI emits `hook_callback`
5//! `control_request`s with an opaque `callback_id` (minted by the SDK) plus
6//! an `input` payload whose `hook_event_name` discriminates concrete types.
7//!
8//! Wire-shape data (`HookKind`, `HookContext`, all `*Input`/`*Output`
9//! structs) lives in forge-primitives, re-exported here. The callback
10//! machinery (`HookCallback` trait, `ErasedHookCallback`,
11//! `Hooks`/`HooksBuilder` registry) stays SDK-side.
12
13pub mod callback;
14pub mod registry;
15
16pub use callback::{ErasedHookCallback, HookCallback, HookDecision};
17pub use forge_primitives::hooks::{
18    BaseHookInput, HookContext, HookKind, NotificationInput, PermissionRequestInput,
19    PostToolUseFailureInput, PostToolUseInput, PreCompactInput, PreToolUseHookSpecificOutput,
20    PreToolUseInput, StopInput, SubagentContext, SubagentStartInput, SubagentStopInput,
21    UserPromptSubmitHookSpecificOutput, UserPromptSubmitInput,
22};
23pub use registry::{Hooks, HooksBuilder};
24
25#[cfg(test)]
26mod tests_hooks_types {
27    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
28    #[allow(unused_imports)]
29    use super::*;
30
31    use crate::{
32        BaseHookInput, HookDecision, HookKind, NotificationInput, PermissionRequestInput,
33        PostToolUseFailureInput, PostToolUseInput, PreCompactInput, PreToolUseInput, StopInput,
34        SubagentContext, SubagentStartInput, SubagentStopInput, UserPromptSubmitInput,
35    };
36    use serde_json::json;
37
38    /// Shared `BaseHookInput` fields the CLI emits on every hook input.
39    #[test]
40    fn base_hook_input_parses_required_fields() {
41        let raw = json!({
42            "session_id": "sess-123",
43            "transcript_path": "/tmp/t.jsonl",
44            "cwd": "/home/u",
45            "permission_mode": "default"
46        });
47        let base: BaseHookInput = serde_json::from_value(raw).expect("parse");
48        assert_eq!(base.session_id, "sess-123");
49        assert_eq!(base.transcript_path, "/tmp/t.jsonl");
50        assert_eq!(base.cwd, "/home/u");
51        assert_eq!(base.permission_mode.as_deref(), Some("default"));
52    }
53
54    /// `permission_mode` is `NotRequired` upstream - must accept frames that omit it.
55    #[test]
56    fn base_hook_input_permission_mode_optional() {
57        let raw = json!({
58            "session_id": "s",
59            "transcript_path": "/t",
60            "cwd": "/c"
61        });
62        let base: BaseHookInput = serde_json::from_value(raw).expect("parse");
63        assert!(base.permission_mode.is_none());
64    }
65
66    #[test]
67    fn pre_tool_use_input_carries_base_and_subagent_fields() {
68        let raw = json!({
69            "hook_event_name": "PreToolUse",
70            "session_id": "sess",
71            "transcript_path": "/t",
72            "cwd": "/c",
73            "permission_mode": "default",
74            "agent_id": "agent-1",
75            "agent_type": "general-purpose",
76            "tool_name": "Bash",
77            "tool_input": {"command": "echo hi"},
78            "tool_use_id": "toolu_1"
79        });
80        let input: PreToolUseInput = serde_json::from_value(raw).expect("parse");
81        assert_eq!(input.base.session_id, "sess");
82        assert_eq!(input.base.transcript_path, "/t");
83        assert_eq!(input.base.cwd, "/c");
84        assert_eq!(input.subagent.agent_id.as_deref(), Some("agent-1"));
85        assert_eq!(input.subagent.agent_type.as_deref(), Some("general-purpose"));
86        assert_eq!(input.tool_name, "Bash");
87        assert_eq!(input.tool_use_id, "toolu_1");
88    }
89
90    #[test]
91    fn pre_tool_use_input_subagent_fields_optional() {
92        // Main-thread hook fires with no agent_id/agent_type.
93        let raw = json!({
94            "hook_event_name": "PreToolUse",
95            "session_id": "s",
96            "transcript_path": "/t",
97            "cwd": "/c",
98            "tool_name": "Bash",
99            "tool_input": {"command": "ls"},
100            "tool_use_id": "toolu_1"
101        });
102        let input: PreToolUseInput = serde_json::from_value(raw).expect("parse");
103        assert!(input.subagent.agent_id.is_none());
104        assert!(input.subagent.agent_type.is_none());
105    }
106
107    #[test]
108    fn post_tool_use_input_carries_tool_response_and_tool_use_id() {
109        let raw = json!({
110            "hook_event_name": "PostToolUse",
111            "session_id": "s",
112            "transcript_path": "/t",
113            "cwd": "/c",
114            "tool_name": "Bash",
115            "tool_input": {"command": "ls"},
116            "tool_response": {"stdout": "a\nb\n"},
117            "tool_use_id": "toolu_9"
118        });
119        let input: PostToolUseInput = serde_json::from_value(raw).expect("parse");
120        assert_eq!(input.tool_name, "Bash");
121        assert_eq!(input.tool_use_id, "toolu_9");
122        assert!(input.tool_response.get("stdout").is_some());
123    }
124
125    #[test]
126    fn user_prompt_submit_input_carries_base_and_prompt() {
127        let raw = json!({
128            "hook_event_name": "UserPromptSubmit",
129            "session_id": "s",
130            "transcript_path": "/t",
131            "cwd": "/c",
132            "prompt": "hi there"
133        });
134        let input: UserPromptSubmitInput = serde_json::from_value(raw).expect("parse");
135        assert_eq!(input.prompt, "hi there");
136        assert_eq!(input.base.session_id, "s");
137    }
138
139    #[test]
140    fn stop_input_has_stop_hook_active_not_num_turns() {
141        // Upstream field is `stop_hook_active: bool`. forge-sdk previously modelled
142        // `num_turns: u64`, which would drop on valid CLI frames.
143        let raw = json!({
144            "hook_event_name": "Stop",
145            "session_id": "s",
146            "transcript_path": "/t",
147            "cwd": "/c",
148            "stop_hook_active": true
149        });
150        let input: StopInput = serde_json::from_value(raw).expect("parse");
151        assert!(input.stop_hook_active);
152    }
153
154    #[test]
155    fn subagent_stop_input_carries_agent_metadata() {
156        let raw = json!({
157            "hook_event_name": "SubagentStop",
158            "session_id": "s",
159            "transcript_path": "/t",
160            "cwd": "/c",
161            "stop_hook_active": false,
162            "agent_id": "agent-1",
163            "agent_transcript_path": "/tmp/agent-1.jsonl",
164            "agent_type": "code-reviewer"
165        });
166        let input: SubagentStopInput = serde_json::from_value(raw).expect("parse");
167        assert!(!input.stop_hook_active);
168        assert_eq!(input.agent_id, "agent-1");
169        assert_eq!(input.agent_transcript_path, "/tmp/agent-1.jsonl");
170        assert_eq!(input.agent_type, "code-reviewer");
171    }
172
173    #[test]
174    fn pre_compact_input_has_trigger_and_custom_instructions() {
175        let raw = json!({
176            "hook_event_name": "PreCompact",
177            "session_id": "s",
178            "transcript_path": "/t",
179            "cwd": "/c",
180            "trigger": "auto",
181            "custom_instructions": null
182        });
183        let input: PreCompactInput = serde_json::from_value(raw).expect("parse");
184        assert_eq!(input.trigger, "auto");
185        assert!(input.custom_instructions.is_none());
186    }
187
188    #[test]
189    fn post_tool_use_failure_input_carries_error_field() {
190        let raw = json!({
191            "hook_event_name": "PostToolUseFailure",
192            "session_id": "s",
193            "transcript_path": "/t",
194            "cwd": "/c",
195            "tool_name": "Bash",
196            "tool_input": {"command": "false"},
197            "tool_use_id": "toolu_x",
198            "error": "exit 1",
199            "is_interrupt": false
200        });
201        let input: PostToolUseFailureInput = serde_json::from_value(raw).expect("parse");
202        assert_eq!(input.error, "exit 1");
203        assert_eq!(input.is_interrupt, Some(false));
204        assert_eq!(input.tool_use_id, "toolu_x");
205    }
206
207    #[test]
208    fn post_tool_use_failure_input_is_interrupt_optional() {
209        // `is_interrupt` is NotRequired upstream - absent frame must parse.
210        let raw = json!({
211            "hook_event_name": "PostToolUseFailure",
212            "session_id": "s",
213            "transcript_path": "/t",
214            "cwd": "/c",
215            "tool_name": "Bash",
216            "tool_input": {"command": "false"},
217            "tool_use_id": "toolu_x",
218            "error": "boom"
219        });
220        let input: PostToolUseFailureInput = serde_json::from_value(raw).expect("parse");
221        assert!(input.is_interrupt.is_none());
222    }
223
224    #[test]
225    fn notification_input_parses() {
226        let raw = json!({
227            "hook_event_name": "Notification",
228            "session_id": "s",
229            "transcript_path": "/t",
230            "cwd": "/c",
231            "message": "Tool needs approval",
232            "title": "Permission",
233            "notification_type": "permission_request"
234        });
235        let input: NotificationInput = serde_json::from_value(raw).expect("parse");
236        assert_eq!(input.message, "Tool needs approval");
237        assert_eq!(input.title.as_deref(), Some("Permission"));
238        assert_eq!(input.notification_type, "permission_request");
239    }
240
241    #[test]
242    fn subagent_start_input_parses() {
243        let raw = json!({
244            "hook_event_name": "SubagentStart",
245            "session_id": "s",
246            "transcript_path": "/t",
247            "cwd": "/c",
248            "agent_id": "agent-1",
249            "agent_type": "code-reviewer"
250        });
251        let input: SubagentStartInput = serde_json::from_value(raw).expect("parse");
252        assert_eq!(input.agent_id, "agent-1");
253        assert_eq!(input.agent_type, "code-reviewer");
254    }
255
256    #[test]
257    fn permission_request_input_parses() {
258        let raw = json!({
259            "hook_event_name": "PermissionRequest",
260            "session_id": "s",
261            "transcript_path": "/t",
262            "cwd": "/c",
263            "tool_name": "Bash",
264            "tool_input": {"command": "rm -rf /"},
265            "permission_suggestions": [{"behavior":"ask"}]
266        });
267        let input: PermissionRequestInput = serde_json::from_value(raw).expect("parse");
268        assert_eq!(input.tool_name, "Bash");
269        assert_eq!(input.permission_suggestions.as_ref().map(Vec::len), Some(1));
270    }
271
272    #[test]
273    fn subagent_context_round_trips() {
274        let ctx = SubagentContext { agent_id: Some("a".into()), agent_type: Some("t".into()) };
275        let v = serde_json::to_value(&ctx).expect("serialise");
276        assert_eq!(v, json!({"agent_id": "a", "agent_type": "t"}));
277        // None fields must not be emitted so flatten stays minimal.
278        let empty = SubagentContext::default();
279        let v2 = serde_json::to_value(empty).expect("serialise");
280        assert_eq!(v2, json!({}));
281    }
282
283    #[test]
284    fn hook_decision_allow() {
285        let d = HookDecision::allow();
286        assert!(d.is_allow());
287    }
288
289    #[test]
290    fn hook_decision_deny() {
291        let d = HookDecision::deny("no go");
292        assert!(!d.is_allow());
293        assert_eq!(d.reason(), Some("no go"));
294    }
295
296    #[test]
297    fn hook_decision_replace_input() {
298        let d = HookDecision::replace_input(json!({"command": "echo safe"}));
299        assert!(d.is_allow());
300        assert!(d.updated_input().is_some());
301    }
302
303    #[test]
304    fn hook_kind_discriminants_cover_all_variants_plus_unknown() {
305        assert_eq!(HookKind::PreToolUse.as_str(), "PreToolUse");
306        assert_eq!(HookKind::PostToolUse.as_str(), "PostToolUse");
307        assert_eq!(HookKind::PostToolUseFailure.as_str(), "PostToolUseFailure");
308        assert_eq!(HookKind::UserPromptSubmit.as_str(), "UserPromptSubmit");
309        assert_eq!(HookKind::Stop.as_str(), "Stop");
310        assert_eq!(HookKind::SubagentStop.as_str(), "SubagentStop");
311        assert_eq!(HookKind::SubagentStart.as_str(), "SubagentStart");
312        assert_eq!(HookKind::PreCompact.as_str(), "PreCompact");
313        assert_eq!(HookKind::Notification.as_str(), "Notification");
314        assert_eq!(HookKind::PermissionRequest.as_str(), "PermissionRequest");
315        assert_eq!(HookKind::WorktreeCreate.as_str(), "WorktreeCreate");
316        assert_eq!(HookKind::WorktreeRemove.as_str(), "WorktreeRemove");
317        assert_eq!(HookKind::CwdChanged.as_str(), "CwdChanged");
318        assert_eq!(HookKind::FileChanged.as_str(), "FileChanged");
319        assert_eq!(HookKind::SessionStart.as_str(), "SessionStart");
320        assert_eq!(HookKind::SessionEnd.as_str(), "SessionEnd");
321        assert_eq!(HookKind::Unknown.as_str(), "Unknown");
322    }
323
324    #[test]
325    fn hook_kind_from_wire_round_trips() {
326        for kind in [
327            HookKind::PreToolUse,
328            HookKind::PostToolUse,
329            HookKind::PostToolUseFailure,
330            HookKind::UserPromptSubmit,
331            HookKind::Stop,
332            HookKind::SubagentStop,
333            HookKind::SubagentStart,
334            HookKind::PreCompact,
335            HookKind::Notification,
336            HookKind::PermissionRequest,
337            HookKind::WorktreeCreate,
338            HookKind::WorktreeRemove,
339            HookKind::CwdChanged,
340            HookKind::FileChanged,
341            HookKind::SessionStart,
342            HookKind::SessionEnd,
343        ] {
344            assert_eq!(HookKind::from_wire(kind.as_str()), kind);
345        }
346        assert_eq!(HookKind::from_wire("BrandNewKind"), HookKind::Unknown);
347    }
348
349    #[test]
350    fn hook_decision_defer_carries_timeout() {
351        let d = HookDecision::defer(Some(5000));
352        assert!(d.is_deferred());
353        assert_eq!(d.defer_timeout_ms(), Some(5000));
354        // Allow / deny predicates don't apply to deferred hooks - is_allow
355        // is still true (no `decision: block` is emitted), but no updated
356        // input should surface.
357        assert!(d.is_allow());
358        assert!(d.updated_input().is_none());
359        assert!(d.reason().is_none());
360    }
361
362    #[test]
363    fn hook_decision_defer_without_timeout_leaves_field_none() {
364        let d = HookDecision::defer(None);
365        assert!(d.is_deferred());
366        assert!(d.defer_timeout_ms().is_none());
367    }
368}