Skip to main content

github_copilot_sdk/
hooks.rs

1//! Lifecycle hook callbacks invoked at key session points.
2//!
3//! Hooks let you intercept and modify CLI behavior — approve or deny tool
4//! use, rewrite user prompts, inject context at session start, and handle
5//! errors. Implement [`SessionHooks`](crate::hooks::SessionHooks) and pass it to
6//! [`Client::create_session`](crate::Client::create_session).
7
8use std::path::PathBuf;
9use std::time::Instant;
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::types::SessionId;
16
17/// Context provided to every hook invocation.
18#[derive(Debug, Clone)]
19pub struct HookContext {
20    /// The session this hook was triggered in.
21    pub session_id: SessionId,
22}
23
24/// Input for the `preToolUse` hook — received before a tool executes.
25#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct PreToolUseInput {
28    /// The runtime session ID of the session that triggered the hook.
29    pub session_id: String,
30    /// Unix timestamp (ms).
31    pub timestamp: i64,
32    /// Working directory.
33    #[serde(rename = "cwd")]
34    pub working_directory: PathBuf,
35    /// Name of the tool about to execute.
36    pub tool_name: String,
37    /// Arguments passed to the tool.
38    pub tool_args: Value,
39}
40
41/// Output for the `preToolUse` hook.
42#[derive(Debug, Clone, Default, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct PreToolUseOutput {
45    /// "allow" or "deny".
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub permission_decision: Option<String>,
48    /// Reason for the decision (shown to the agent).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub permission_decision_reason: Option<String>,
51    /// Replacement arguments for the tool.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub modified_args: Option<Value>,
54    /// Extra context injected into the agent's prompt.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub additional_context: Option<String>,
57    /// Suppress the hook's output from the session log.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub suppress_output: Option<bool>,
60}
61
62/// Input for the `preMcpToolCall` hook — received before an MCP tool call is dispatched.
63#[derive(Debug, Clone, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct PreMcpToolCallInput {
66    /// The runtime session ID of the session that triggered the hook.
67    pub session_id: String,
68    /// Unix timestamp (ms).
69    pub timestamp: i64,
70    /// Working directory.
71    #[serde(rename = "cwd")]
72    pub working_directory: PathBuf,
73    /// Name of the MCP server being called.
74    pub server_name: String,
75    /// Name of the MCP tool being called.
76    pub tool_name: String,
77    /// Arguments for the MCP tool call.
78    pub arguments: Value,
79    /// Tool call ID, if available.
80    #[serde(default)]
81    pub tool_call_id: Option<String>,
82    /// MCP request metadata.
83    #[serde(default, rename = "_meta")]
84    pub meta: Option<Value>,
85}
86
87/// Output for the `preMcpToolCall` hook.
88///
89/// `meta_to_use` has tri-state semantics:
90/// - `None`: field is absent in JSON, meaning preserve existing `_meta`
91/// - `Some(Value::Null)`: serialized as JSON `null`, meaning omit `_meta`
92/// - `Some(Value::Object(...))`: serialized as JSON object, meaning replace `_meta`
93#[derive(Debug, Clone, Default, Serialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PreMcpToolCallOutput {
96    /// Hook-controlled metadata for the outgoing MCP request.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub meta_to_use: Option<Value>,
99}
100
101/// Input for the `postToolUse` hook — received after a tool executes.
102#[derive(Debug, Clone, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct PostToolUseInput {
105    /// The runtime session ID of the session that triggered the hook.
106    pub session_id: String,
107    /// Unix timestamp (ms).
108    pub timestamp: i64,
109    /// Working directory.
110    #[serde(rename = "cwd")]
111    pub working_directory: PathBuf,
112    /// Name of the tool that executed.
113    pub tool_name: String,
114    /// Arguments that were passed to the tool.
115    pub tool_args: Value,
116    /// Result returned by the tool.
117    pub tool_result: Value,
118}
119
120/// Output for the `postToolUse` hook.
121#[derive(Debug, Clone, Default, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub struct PostToolUseOutput {
124    /// Replacement result for the tool.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub modified_result: Option<Value>,
127    /// Extra context injected into the agent's prompt.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub additional_context: Option<String>,
130    /// Suppress the hook's output from the session log.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub suppress_output: Option<bool>,
133}
134
135/// Input for the `userPromptSubmitted` hook — received when the user sends a message.
136#[derive(Debug, Clone, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct UserPromptSubmittedInput {
139    /// The runtime session ID of the session that triggered the hook.
140    pub session_id: String,
141    /// Unix timestamp (ms).
142    pub timestamp: i64,
143    /// Working directory.
144    #[serde(rename = "cwd")]
145    pub working_directory: PathBuf,
146    /// The user's message text.
147    pub prompt: String,
148}
149
150/// Output for the `userPromptSubmitted` hook.
151#[derive(Debug, Clone, Default, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct UserPromptSubmittedOutput {
154    /// Replacement prompt text.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub modified_prompt: Option<String>,
157    /// Extra context injected into the agent's prompt.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub additional_context: Option<String>,
160    /// Suppress the hook's output from the session log.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub suppress_output: Option<bool>,
163}
164
165/// Input for the `sessionStart` hook.
166#[derive(Debug, Clone, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct SessionStartInput {
169    /// The runtime session ID of the session that triggered the hook.
170    pub session_id: String,
171    /// Unix timestamp (ms).
172    pub timestamp: i64,
173    /// Working directory.
174    #[serde(rename = "cwd")]
175    pub working_directory: PathBuf,
176    /// How the session was started: `"startup"`, `"resume"`, or `"new"`.
177    pub source: String,
178    /// The first user message, if any.
179    #[serde(default)]
180    pub initial_prompt: Option<String>,
181}
182
183/// Output for the `sessionStart` hook.
184#[derive(Debug, Clone, Default, Serialize)]
185#[serde(rename_all = "camelCase")]
186pub struct SessionStartOutput {
187    /// Extra context injected at session start.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub additional_context: Option<String>,
190    /// Config overrides applied to the session.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub modified_config: Option<Value>,
193}
194
195/// Input for the `sessionEnd` hook.
196#[derive(Debug, Clone, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct SessionEndInput {
199    /// The runtime session ID of the session that triggered the hook.
200    pub session_id: String,
201    /// Unix timestamp (ms).
202    pub timestamp: i64,
203    /// Working directory.
204    #[serde(rename = "cwd")]
205    pub working_directory: PathBuf,
206    /// Why the session ended: `"complete"`, `"error"`, `"abort"`, `"timeout"`, `"user_exit"`.
207    pub reason: String,
208    /// The last assistant message.
209    #[serde(default)]
210    pub final_message: Option<String>,
211    /// Error message, if the session ended due to an error.
212    #[serde(default)]
213    pub error: Option<String>,
214}
215
216/// Output for the `sessionEnd` hook.
217#[derive(Debug, Clone, Default, Serialize)]
218#[serde(rename_all = "camelCase")]
219pub struct SessionEndOutput {
220    /// Suppress the hook's output from the session log.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub suppress_output: Option<bool>,
223    /// Actions to run during cleanup.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub cleanup_actions: Option<Vec<String>>,
226    /// Summary text for the session.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub session_summary: Option<String>,
229}
230
231/// Input for the `errorOccurred` hook.
232#[derive(Debug, Clone, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct ErrorOccurredInput {
235    /// The runtime session ID of the session that triggered the hook.
236    pub session_id: String,
237    /// Unix timestamp (ms).
238    pub timestamp: i64,
239    /// Working directory.
240    #[serde(rename = "cwd")]
241    pub working_directory: PathBuf,
242    /// The error message.
243    pub error: String,
244    /// Context where the error occurred: `"model_call"`, `"tool_execution"`, `"system"`, `"user_input"`.
245    pub error_context: String,
246    /// Whether the error is recoverable.
247    pub recoverable: bool,
248}
249
250/// Output for the `errorOccurred` hook.
251#[derive(Debug, Clone, Default, Serialize)]
252#[serde(rename_all = "camelCase")]
253pub struct ErrorOccurredOutput {
254    /// Suppress the hook's output from the session log.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub suppress_output: Option<bool>,
257    /// How to handle the error: `"retry"`, `"skip"`, or `"abort"`.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub error_handling: Option<String>,
260    /// Number of retries to attempt.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub retry_count: Option<u32>,
263    /// Message to show the user.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub user_notification: Option<String>,
266}
267
268/// Events dispatched to [`SessionHooks::on_hook`] at CLI lifecycle points.
269///
270/// Each variant carries the typed input for that hook plus the shared
271/// [`HookContext`]. The handler returns a matching [`HookOutput`] variant
272/// (or [`HookOutput::None`] to signal "no hook registered").
273#[non_exhaustive]
274#[derive(Debug)]
275pub enum HookEvent {
276    /// Fired before a tool executes.
277    PreToolUse {
278        /// Typed input data.
279        input: PreToolUseInput,
280        /// Session context.
281        ctx: HookContext,
282    },
283    /// Fired before an MCP tool call is dispatched.
284    PreMcpToolCall {
285        /// Typed input data.
286        input: PreMcpToolCallInput,
287        /// Session context.
288        ctx: HookContext,
289    },
290    /// Fired after a tool executes.
291    PostToolUse {
292        /// Typed input data.
293        input: PostToolUseInput,
294        /// Session context.
295        ctx: HookContext,
296    },
297    /// Fired when the user sends a message.
298    UserPromptSubmitted {
299        /// Typed input data.
300        input: UserPromptSubmittedInput,
301        /// Session context.
302        ctx: HookContext,
303    },
304    /// Fired at session creation or resume.
305    SessionStart {
306        /// Typed input data.
307        input: SessionStartInput,
308        /// Session context.
309        ctx: HookContext,
310    },
311    /// Fired when the session ends.
312    SessionEnd {
313        /// Typed input data.
314        input: SessionEndInput,
315        /// Session context.
316        ctx: HookContext,
317    },
318    /// Fired when an error occurs.
319    ErrorOccurred {
320        /// Typed input data.
321        input: ErrorOccurredInput,
322        /// Session context.
323        ctx: HookContext,
324    },
325}
326
327/// Response from [`SessionHooks::on_hook`] back to the SDK.
328///
329/// Return the variant matching the [`HookEvent`] you received, or
330/// [`HookOutput::None`] to indicate no hook is registered for that event.
331#[non_exhaustive]
332#[derive(Debug)]
333pub enum HookOutput {
334    /// No hook registered — the SDK returns an empty output object to the CLI.
335    None,
336    /// Response for a pre-tool-use hook.
337    PreToolUse(PreToolUseOutput),
338    /// Response for a pre-MCP-tool-call hook.
339    PreMcpToolCall(PreMcpToolCallOutput),
340    /// Response for a post-tool-use hook.
341    PostToolUse(PostToolUseOutput),
342    /// Response for a user-prompt-submitted hook.
343    UserPromptSubmitted(UserPromptSubmittedOutput),
344    /// Response for a session-start hook.
345    SessionStart(SessionStartOutput),
346    /// Response for a session-end hook.
347    SessionEnd(SessionEndOutput),
348    /// Response for an error-occurred hook.
349    ErrorOccurred(ErrorOccurredOutput),
350}
351
352impl HookOutput {
353    fn variant_name(&self) -> &'static str {
354        match self {
355            Self::None => "None",
356            Self::PreToolUse(_) => "PreToolUse",
357            Self::PreMcpToolCall(_) => "PreMcpToolCall",
358            Self::PostToolUse(_) => "PostToolUse",
359            Self::UserPromptSubmitted(_) => "UserPromptSubmitted",
360            Self::SessionStart(_) => "SessionStart",
361            Self::SessionEnd(_) => "SessionEnd",
362            Self::ErrorOccurred(_) => "ErrorOccurred",
363        }
364    }
365}
366
367/// Callback trait for session hooks — invoked by the CLI at key lifecycle
368/// points (tool use, prompt submission, session start/end, errors).
369///
370/// Implement this trait to intercept and modify CLI behavior at hook points.
371/// There are two styles of implementation — pick whichever fits:
372///
373/// 1. **Per-hook methods (recommended).** Override the specific `on_*` hook
374///    methods you care about; every hook has a default that returns `None`
375///    (meaning "no hook registered, use CLI default behavior").
376/// 2. **Single [`on_hook`](Self::on_hook) method.** Override this one and
377///    `match` on [`HookEvent`] yourself — useful for logging middleware or
378///    shared dispatch logic.
379///
380/// Hooks only fire when hooks are enabled on the session (via
381/// [`SessionConfig::hooks = Some(true)`](crate::types::SessionConfig::hooks),
382/// which [`SessionConfig::with_hooks`](crate::types::SessionConfig::with_hooks)
383/// sets automatically).
384#[async_trait]
385pub trait SessionHooks: Send + Sync + 'static {
386    /// Top-level dispatch. The default implementation fans out to the
387    /// per-hook methods below; override this only if you want a single
388    /// matching point across all hook types.
389    async fn on_hook(&self, event: HookEvent) -> HookOutput {
390        match event {
391            HookEvent::PreToolUse { input, ctx } => self
392                .on_pre_tool_use(input, ctx)
393                .await
394                .map(HookOutput::PreToolUse)
395                .unwrap_or(HookOutput::None),
396            HookEvent::PreMcpToolCall { input, ctx } => self
397                .on_pre_mcp_tool_call(input, ctx)
398                .await
399                .map(HookOutput::PreMcpToolCall)
400                .unwrap_or(HookOutput::None),
401            HookEvent::PostToolUse { input, ctx } => self
402                .on_post_tool_use(input, ctx)
403                .await
404                .map(HookOutput::PostToolUse)
405                .unwrap_or(HookOutput::None),
406            HookEvent::UserPromptSubmitted { input, ctx } => self
407                .on_user_prompt_submitted(input, ctx)
408                .await
409                .map(HookOutput::UserPromptSubmitted)
410                .unwrap_or(HookOutput::None),
411            HookEvent::SessionStart { input, ctx } => self
412                .on_session_start(input, ctx)
413                .await
414                .map(HookOutput::SessionStart)
415                .unwrap_or(HookOutput::None),
416            HookEvent::SessionEnd { input, ctx } => self
417                .on_session_end(input, ctx)
418                .await
419                .map(HookOutput::SessionEnd)
420                .unwrap_or(HookOutput::None),
421            HookEvent::ErrorOccurred { input, ctx } => self
422                .on_error_occurred(input, ctx)
423                .await
424                .map(HookOutput::ErrorOccurred)
425                .unwrap_or(HookOutput::None),
426        }
427    }
428
429    /// Called before a tool executes. Return `Some(output)` to approve/deny
430    /// or modify the call, or `None` (default) to pass through unchanged.
431    async fn on_pre_tool_use(
432        &self,
433        _input: PreToolUseInput,
434        _ctx: HookContext,
435    ) -> Option<PreToolUseOutput> {
436        None
437    }
438
439    /// Called before an MCP tool call is dispatched. Return `Some(output)` to
440    /// modify or remove request metadata, or `None` (default) to pass through unchanged.
441    async fn on_pre_mcp_tool_call(
442        &self,
443        _input: PreMcpToolCallInput,
444        _ctx: HookContext,
445    ) -> Option<PreMcpToolCallOutput> {
446        None
447    }
448
449    /// Called after a tool executes. Return `Some(output)` to inject
450    /// additional context or signal post-processing decisions; `None`
451    /// (default) means no follow-up.
452    async fn on_post_tool_use(
453        &self,
454        _input: PostToolUseInput,
455        _ctx: HookContext,
456    ) -> Option<PostToolUseOutput> {
457        None
458    }
459
460    /// Called when the user submits a prompt. Return `Some(output)` to
461    /// rewrite the prompt or inject extra context; `None` (default) passes
462    /// through unchanged.
463    async fn on_user_prompt_submitted(
464        &self,
465        _input: UserPromptSubmittedInput,
466        _ctx: HookContext,
467    ) -> Option<UserPromptSubmittedOutput> {
468        None
469    }
470
471    /// Called at session creation or resume. Return `Some(output)` to
472    /// inject startup context.
473    async fn on_session_start(
474        &self,
475        _input: SessionStartInput,
476        _ctx: HookContext,
477    ) -> Option<SessionStartOutput> {
478        None
479    }
480
481    /// Called when the session ends. Return `Some(output)` if your hook
482    /// needs to signal cleanup behavior.
483    async fn on_session_end(
484        &self,
485        _input: SessionEndInput,
486        _ctx: HookContext,
487    ) -> Option<SessionEndOutput> {
488        None
489    }
490
491    /// Called when the CLI reports an error. Return `Some(output)` to
492    /// influence retry behavior or surface a user-facing notification.
493    async fn on_error_occurred(
494        &self,
495        _input: ErrorOccurredInput,
496        _ctx: HookContext,
497    ) -> Option<ErrorOccurredOutput> {
498        None
499    }
500}
501
502/// Dispatches a `hooks.invoke` request to [`SessionHooks::on_hook`].
503///
504/// Returns `Ok(Value)` shaped like `{ "output": ... }` on success.
505/// If no hook is registered ([`HookOutput::None`]), the output is an empty
506/// object: `{ "output": {} }`.
507pub(crate) async fn dispatch_hook(
508    hooks: &dyn SessionHooks,
509    session_id: &SessionId,
510    hook_type: &str,
511    raw_input: Value,
512) -> Result<Value, crate::Error> {
513    let ctx = HookContext {
514        session_id: session_id.clone(),
515    };
516
517    let event = match hook_type {
518        "preToolUse" => {
519            let input: PreToolUseInput = serde_json::from_value(raw_input)?;
520            HookEvent::PreToolUse { input, ctx }
521        }
522        "preMcpToolCall" => {
523            let input: PreMcpToolCallInput = serde_json::from_value(raw_input)?;
524            HookEvent::PreMcpToolCall { input, ctx }
525        }
526        "postToolUse" => {
527            let input: PostToolUseInput = serde_json::from_value(raw_input)?;
528            HookEvent::PostToolUse { input, ctx }
529        }
530        "userPromptSubmitted" => {
531            let input: UserPromptSubmittedInput = serde_json::from_value(raw_input)?;
532            HookEvent::UserPromptSubmitted { input, ctx }
533        }
534        "sessionStart" => {
535            let input: SessionStartInput = serde_json::from_value(raw_input)?;
536            HookEvent::SessionStart { input, ctx }
537        }
538        "sessionEnd" => {
539            let input: SessionEndInput = serde_json::from_value(raw_input)?;
540            HookEvent::SessionEnd { input, ctx }
541        }
542        "errorOccurred" => {
543            let input: ErrorOccurredInput = serde_json::from_value(raw_input)?;
544            HookEvent::ErrorOccurred { input, ctx }
545        }
546        _ => {
547            tracing::warn!(
548                hook_type = hook_type,
549                session_id = %session_id,
550                "unknown hook type"
551            );
552            return Ok(serde_json::json!({ "output": {} }));
553        }
554    };
555
556    let dispatch_start = Instant::now();
557    let output = hooks.on_hook(event).await;
558    tracing::debug!(
559        elapsed_ms = dispatch_start.elapsed().as_millis(),
560        session_id = %session_id,
561        hook_type = hook_type,
562        "SessionHooks::on_hook dispatch"
563    );
564
565    // Validate that the output variant matches the dispatched hook type.
566    // A mismatched return (e.g. HookOutput::SessionEnd for a preToolUse
567    // event) is treated as "no hook registered" to avoid sending the CLI
568    // a semantically wrong response.
569    let output_value = match (hook_type, &output) {
570        (_, HookOutput::None) => None,
571        ("preToolUse", HookOutput::PreToolUse(o)) => Some(serde_json::to_value(o)?),
572        ("preMcpToolCall", HookOutput::PreMcpToolCall(o)) => Some(serde_json::to_value(o)?),
573        ("postToolUse", HookOutput::PostToolUse(o)) => Some(serde_json::to_value(o)?),
574        ("userPromptSubmitted", HookOutput::UserPromptSubmitted(o)) => {
575            Some(serde_json::to_value(o)?)
576        }
577        ("sessionStart", HookOutput::SessionStart(o)) => Some(serde_json::to_value(o)?),
578        ("sessionEnd", HookOutput::SessionEnd(o)) => Some(serde_json::to_value(o)?),
579        ("errorOccurred", HookOutput::ErrorOccurred(o)) => Some(serde_json::to_value(o)?),
580        _ => {
581            tracing::warn!(
582                hook_type = hook_type,
583                session_id = %session_id,
584                output_variant = output.variant_name(),
585                "hook returned mismatched output variant, treating as unregistered"
586            );
587            None
588        }
589    };
590
591    Ok(serde_json::json!({ "output": output_value.unwrap_or(Value::Object(Default::default())) }))
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    struct TestHooks;
599
600    #[async_trait]
601    impl SessionHooks for TestHooks {
602        async fn on_hook(&self, event: HookEvent) -> HookOutput {
603            match event {
604                HookEvent::PreToolUse { input, .. } => {
605                    if input.tool_name == "dangerous_tool" {
606                        HookOutput::PreToolUse(PreToolUseOutput {
607                            permission_decision: Some("deny".to_string()),
608                            permission_decision_reason: Some("blocked by policy".to_string()),
609                            ..Default::default()
610                        })
611                    } else {
612                        HookOutput::None
613                    }
614                }
615                HookEvent::UserPromptSubmitted { input, .. } => {
616                    HookOutput::UserPromptSubmitted(UserPromptSubmittedOutput {
617                        modified_prompt: Some(format!("[prefixed] {}", input.prompt)),
618                        ..Default::default()
619                    })
620                }
621                _ => HookOutput::None,
622            }
623        }
624    }
625
626    #[tokio::test]
627    async fn dispatch_pre_tool_use_deny() {
628        let hooks = TestHooks;
629        let input = serde_json::json!({
630            "sessionId": "sess-1",
631            "timestamp": 1234567890,
632            "cwd": "/tmp",
633            "toolName": "dangerous_tool",
634            "toolArgs": {}
635        });
636        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
637            .await
638            .unwrap();
639        let output = &result["output"];
640        assert_eq!(output["permissionDecision"], "deny");
641        assert_eq!(output["permissionDecisionReason"], "blocked by policy");
642    }
643
644    #[tokio::test]
645    async fn dispatch_pre_tool_use_passthrough() {
646        let hooks = TestHooks;
647        let input = serde_json::json!({
648            "sessionId": "sess-1",
649            "timestamp": 1234567890,
650            "cwd": "/tmp",
651            "toolName": "safe_tool",
652            "toolArgs": {"key": "value"}
653        });
654        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
655            .await
656            .unwrap();
657        // No hook registered for this tool — output should be empty object
658        assert_eq!(result["output"], serde_json::json!({}));
659    }
660
661    #[tokio::test]
662    async fn dispatch_user_prompt_submitted() {
663        let hooks = TestHooks;
664        let input = serde_json::json!({
665            "sessionId": "sess-1",
666            "timestamp": 1234567890,
667            "cwd": "/tmp",
668            "prompt": "hello world"
669        });
670        let result = dispatch_hook(
671            &hooks,
672            &SessionId::new("sess-1"),
673            "userPromptSubmitted",
674            input,
675        )
676        .await
677        .unwrap();
678        assert_eq!(result["output"]["modifiedPrompt"], "[prefixed] hello world");
679    }
680
681    #[tokio::test]
682    async fn dispatch_unregistered_hook_returns_empty() {
683        let hooks = TestHooks;
684        let input = serde_json::json!({
685            "sessionId": "sess-1",
686            "timestamp": 1234567890,
687            "cwd": "/tmp",
688            "reason": "complete"
689        });
690        // TestHooks doesn't handle SessionEnd
691        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionEnd", input)
692            .await
693            .unwrap();
694        assert_eq!(result["output"], serde_json::json!({}));
695    }
696
697    #[tokio::test]
698    async fn dispatch_unknown_hook_type() {
699        let hooks = TestHooks;
700        let input = serde_json::json!({});
701        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "unknownHook", input)
702            .await
703            .unwrap();
704        assert_eq!(result["output"], serde_json::json!({}));
705    }
706
707    #[tokio::test]
708    async fn dispatch_mismatched_output_returns_empty() {
709        struct MismatchHooks;
710        #[async_trait]
711        impl SessionHooks for MismatchHooks {
712            async fn on_hook(&self, _event: HookEvent) -> HookOutput {
713                // Always return SessionEnd output regardless of event type
714                HookOutput::SessionEnd(SessionEndOutput {
715                    session_summary: Some("oops".to_string()),
716                    ..Default::default()
717                })
718            }
719        }
720
721        let hooks = MismatchHooks;
722        let input = serde_json::json!({
723            "sessionId": "sess-1",
724            "timestamp": 1234567890,
725            "cwd": "/tmp",
726            "toolName": "some_tool",
727            "toolArgs": {}
728        });
729        // preToolUse event gets a SessionEnd output — should be treated as empty
730        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
731            .await
732            .unwrap();
733        assert_eq!(result["output"], serde_json::json!({}));
734    }
735
736    #[tokio::test]
737    async fn dispatch_post_tool_use_default() {
738        let hooks = TestHooks;
739        let input = serde_json::json!({
740            "sessionId": "sess-1",
741            "timestamp": 1234567890,
742            "cwd": "/tmp",
743            "toolName": "some_tool",
744            "toolArgs": {},
745            "toolResult": "success"
746        });
747        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "postToolUse", input)
748            .await
749            .unwrap();
750        assert_eq!(result["output"], serde_json::json!({}));
751    }
752
753    #[tokio::test]
754    async fn dispatch_session_start() {
755        struct StartHooks;
756        #[async_trait]
757        impl SessionHooks for StartHooks {
758            async fn on_hook(&self, event: HookEvent) -> HookOutput {
759                match event {
760                    HookEvent::SessionStart { .. } => {
761                        HookOutput::SessionStart(SessionStartOutput {
762                            additional_context: Some("extra context".to_string()),
763                            ..Default::default()
764                        })
765                    }
766                    _ => HookOutput::None,
767                }
768            }
769        }
770
771        let hooks = StartHooks;
772        let input = serde_json::json!({
773            "sessionId": "sess-1",
774            "timestamp": 1234567890,
775            "cwd": "/tmp",
776            "source": "new"
777        });
778        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionStart", input)
779            .await
780            .unwrap();
781        assert_eq!(result["output"]["additionalContext"], "extra context");
782    }
783
784    #[tokio::test]
785    async fn dispatch_error_occurred() {
786        struct ErrorHooks;
787        #[async_trait]
788        impl SessionHooks for ErrorHooks {
789            async fn on_hook(&self, event: HookEvent) -> HookOutput {
790                match event {
791                    HookEvent::ErrorOccurred { .. } => {
792                        HookOutput::ErrorOccurred(ErrorOccurredOutput {
793                            error_handling: Some("retry".to_string()),
794                            retry_count: Some(3),
795                            ..Default::default()
796                        })
797                    }
798                    _ => HookOutput::None,
799                }
800            }
801        }
802
803        let hooks = ErrorHooks;
804        let input = serde_json::json!({
805            "sessionId": "sess-1",
806            "timestamp": 1234567890,
807            "cwd": "/tmp",
808            "error": "model timeout",
809            "errorContext": "model_call",
810            "recoverable": true
811        });
812        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "errorOccurred", input)
813            .await
814            .unwrap();
815        assert_eq!(result["output"]["errorHandling"], "retry");
816        assert_eq!(result["output"]["retryCount"], 3);
817    }
818}