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 in ms (the runtime serializes this as a JSON float).
31    pub timestamp: f64,
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 in ms (the runtime serializes this as a JSON float).
69    pub timestamp: f64,
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 in ms (the runtime serializes this as a JSON float).
108    pub timestamp: f64,
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 `postToolUseFailure` hook — received after a tool execution
136/// whose result was `"failure"`.
137///
138/// `postToolUse` only fires for successful tool executions. Register a handler
139/// for `postToolUseFailure` to observe failed tool calls. The CLI extracts the
140/// failure message from the tool result and passes it as the `error` field
141/// (rather than passing the full result object).
142#[derive(Debug, Clone, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct PostToolUseFailureInput {
145    /// The runtime session ID of the session that triggered the hook.
146    pub session_id: String,
147    /// Unix timestamp in ms (the runtime serializes this as a JSON float).
148    pub timestamp: f64,
149    /// Working directory.
150    #[serde(rename = "cwd")]
151    pub working_directory: PathBuf,
152    /// Name of the tool that failed.
153    pub tool_name: String,
154    /// Arguments that were passed to the tool.
155    pub tool_args: Value,
156    /// Failure message extracted from the tool's result.
157    pub error: String,
158}
159
160/// Output for the `postToolUseFailure` hook.
161///
162/// Only `additional_context` is consumed by the host CLI — it is appended as
163/// hidden guidance to the model alongside the failed tool result.
164#[derive(Debug, Clone, Default, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct PostToolUseFailureOutput {
167    /// Extra context appended to the failed tool result for the agent.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub additional_context: Option<String>,
170}
171
172/// Input for the `userPromptSubmitted` hook — received when the user sends a message.
173#[derive(Debug, Clone, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct UserPromptSubmittedInput {
176    /// The runtime session ID of the session that triggered the hook.
177    pub session_id: String,
178    /// Unix timestamp in ms (the runtime serializes this as a JSON float).
179    pub timestamp: f64,
180    /// Working directory.
181    #[serde(rename = "cwd")]
182    pub working_directory: PathBuf,
183    /// The user's message text.
184    pub prompt: String,
185}
186
187/// Output for the `userPromptSubmitted` hook.
188#[derive(Debug, Clone, Default, Serialize)]
189#[serde(rename_all = "camelCase")]
190pub struct UserPromptSubmittedOutput {
191    /// Replacement prompt text.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub modified_prompt: Option<String>,
194    /// Extra context injected into the agent's prompt.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub additional_context: Option<String>,
197    /// Suppress the hook's output from the session log.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub suppress_output: Option<bool>,
200}
201
202/// Input for the `sessionStart` hook.
203#[derive(Debug, Clone, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct SessionStartInput {
206    /// The runtime session ID of the session that triggered the hook.
207    pub session_id: String,
208    /// Unix timestamp in ms (the runtime serializes this as a JSON float).
209    pub timestamp: f64,
210    /// Working directory.
211    #[serde(rename = "cwd")]
212    pub working_directory: PathBuf,
213    /// How the session was started: `"startup"`, `"resume"`, or `"new"`.
214    pub source: String,
215    /// The first user message, if any.
216    #[serde(default)]
217    pub initial_prompt: Option<String>,
218}
219
220/// Output for the `sessionStart` hook.
221#[derive(Debug, Clone, Default, Serialize)]
222#[serde(rename_all = "camelCase")]
223pub struct SessionStartOutput {
224    /// Extra context injected at session start.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub additional_context: Option<String>,
227    /// Config overrides applied to the session.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub modified_config: Option<Value>,
230}
231
232/// Input for the `sessionEnd` hook.
233#[derive(Debug, Clone, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct SessionEndInput {
236    /// The runtime session ID of the session that triggered the hook.
237    pub session_id: String,
238    /// Unix timestamp in ms (the runtime serializes this as a JSON float).
239    pub timestamp: f64,
240    /// Working directory.
241    #[serde(rename = "cwd")]
242    pub working_directory: PathBuf,
243    /// Why the session ended: `"complete"`, `"error"`, `"abort"`, `"timeout"`, `"user_exit"`.
244    pub reason: String,
245    /// The last assistant message.
246    #[serde(default)]
247    pub final_message: Option<String>,
248    /// Error message, if the session ended due to an error.
249    #[serde(default)]
250    pub error: Option<String>,
251}
252
253/// Output for the `sessionEnd` hook.
254#[derive(Debug, Clone, Default, Serialize)]
255#[serde(rename_all = "camelCase")]
256pub struct SessionEndOutput {
257    /// Suppress the hook's output from the session log.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub suppress_output: Option<bool>,
260    /// Actions to run during cleanup.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub cleanup_actions: Option<Vec<String>>,
263    /// Summary text for the session.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub session_summary: Option<String>,
266}
267
268/// Input for the `errorOccurred` hook.
269#[derive(Debug, Clone, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct ErrorOccurredInput {
272    /// The runtime session ID of the session that triggered the hook.
273    pub session_id: String,
274    /// Unix timestamp in ms (the runtime serializes this as a JSON float).
275    pub timestamp: f64,
276    /// Working directory.
277    #[serde(rename = "cwd")]
278    pub working_directory: PathBuf,
279    /// The error message.
280    pub error: String,
281    /// Context where the error occurred: `"model_call"`, `"tool_execution"`, `"system"`, `"user_input"`.
282    pub error_context: String,
283    /// Whether the error is recoverable.
284    pub recoverable: bool,
285}
286
287/// Output for the `errorOccurred` hook.
288#[derive(Debug, Clone, Default, Serialize)]
289#[serde(rename_all = "camelCase")]
290pub struct ErrorOccurredOutput {
291    /// Suppress the hook's output from the session log.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub suppress_output: Option<bool>,
294    /// How to handle the error: `"retry"`, `"skip"`, or `"abort"`.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub error_handling: Option<String>,
297    /// Number of retries to attempt.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub retry_count: Option<u32>,
300    /// Message to show the user.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub user_notification: Option<String>,
303}
304
305/// Input for the `agentStop` hook, received when the top-level agent reaches a natural stop.
306#[derive(Debug, Clone, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct AgentStopInput {
309    /// The runtime session ID of the session that triggered the hook.
310    pub session_id: String,
311    /// Unix timestamp in ms (the runtime serializes this as a JSON float).
312    pub timestamp: f64,
313    /// Working directory.
314    #[serde(rename = "cwd")]
315    pub working_directory: PathBuf,
316    /// Reason the agent stopped.
317    #[serde(default)]
318    pub stop_reason: Option<String>,
319    /// Path to the on-disk session transcript.
320    #[serde(default)]
321    pub transcript_path: Option<PathBuf>,
322    /// Whether this stop follows a previous block decision from the hook.
323    #[serde(default, rename = "stop_hook_active")]
324    pub stop_hook_active: Option<bool>,
325}
326
327/// Output for the `agentStop` hook.
328#[derive(Debug, Clone, Default, Serialize)]
329#[serde(rename_all = "camelCase")]
330pub struct AgentStopOutput {
331    /// Set to `"block"` to keep the agent running.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub decision: Option<String>,
334    /// Follow-up instruction supplied when the stop is blocked.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub reason: Option<String>,
337}
338
339/// Events dispatched to [`SessionHooks::on_hook`] at CLI lifecycle points.
340///
341/// Each variant carries the typed input for that hook plus the shared
342/// [`HookContext`]. The handler returns a matching [`HookOutput`] variant
343/// (or [`HookOutput::None`] to signal "no hook registered").
344#[non_exhaustive]
345#[derive(Debug)]
346pub enum HookEvent {
347    /// Fired before a tool executes.
348    PreToolUse {
349        /// Typed input data.
350        input: PreToolUseInput,
351        /// Session context.
352        ctx: HookContext,
353    },
354    /// Fired before an MCP tool call is dispatched.
355    PreMcpToolCall {
356        /// Typed input data.
357        input: PreMcpToolCallInput,
358        /// Session context.
359        ctx: HookContext,
360    },
361    /// Fired after a tool executes.
362    PostToolUse {
363        /// Typed input data.
364        input: PostToolUseInput,
365        /// Session context.
366        ctx: HookContext,
367    },
368    /// Fired after a tool execution whose result was `"failure"`.
369    /// [`HookEvent::PostToolUse`] only fires on success, so observe this
370    /// variant to react to failed tool calls.
371    PostToolUseFailure {
372        /// Typed input data.
373        input: PostToolUseFailureInput,
374        /// Session context.
375        ctx: HookContext,
376    },
377    /// Fired when the user sends a message.
378    UserPromptSubmitted {
379        /// Typed input data.
380        input: UserPromptSubmittedInput,
381        /// Session context.
382        ctx: HookContext,
383    },
384    /// Fired at session creation or resume.
385    SessionStart {
386        /// Typed input data.
387        input: SessionStartInput,
388        /// Session context.
389        ctx: HookContext,
390    },
391    /// Fired when the session ends.
392    SessionEnd {
393        /// Typed input data.
394        input: SessionEndInput,
395        /// Session context.
396        ctx: HookContext,
397    },
398    /// Fired when an error occurs.
399    ErrorOccurred {
400        /// Typed input data.
401        input: ErrorOccurredInput,
402        /// Session context.
403        ctx: HookContext,
404    },
405    /// Fired when the top-level agent reaches a natural stop.
406    AgentStop {
407        /// Typed input data.
408        input: AgentStopInput,
409        /// Session context.
410        ctx: HookContext,
411    },
412}
413
414/// Response from [`SessionHooks::on_hook`] back to the SDK.
415///
416/// Return the variant matching the [`HookEvent`] you received, or
417/// [`HookOutput::None`] to indicate no hook is registered for that event.
418#[non_exhaustive]
419#[derive(Debug)]
420pub enum HookOutput {
421    /// No hook registered — the SDK returns an empty output object to the CLI.
422    None,
423    /// Response for a pre-tool-use hook.
424    PreToolUse(PreToolUseOutput),
425    /// Response for a pre-MCP-tool-call hook.
426    PreMcpToolCall(PreMcpToolCallOutput),
427    /// Response for a post-tool-use hook.
428    PostToolUse(PostToolUseOutput),
429    /// Response for a post-tool-use-failure hook.
430    PostToolUseFailure(PostToolUseFailureOutput),
431    /// Response for a user-prompt-submitted hook.
432    UserPromptSubmitted(UserPromptSubmittedOutput),
433    /// Response for a session-start hook.
434    SessionStart(SessionStartOutput),
435    /// Response for a session-end hook.
436    SessionEnd(SessionEndOutput),
437    /// Response for an error-occurred hook.
438    ErrorOccurred(ErrorOccurredOutput),
439    /// Response for an agent-stop hook.
440    AgentStop(AgentStopOutput),
441}
442
443impl HookOutput {
444    fn variant_name(&self) -> &'static str {
445        match self {
446            Self::None => "None",
447            Self::PreToolUse(_) => "PreToolUse",
448            Self::PreMcpToolCall(_) => "PreMcpToolCall",
449            Self::PostToolUse(_) => "PostToolUse",
450            Self::PostToolUseFailure(_) => "PostToolUseFailure",
451            Self::UserPromptSubmitted(_) => "UserPromptSubmitted",
452            Self::SessionStart(_) => "SessionStart",
453            Self::SessionEnd(_) => "SessionEnd",
454            Self::ErrorOccurred(_) => "ErrorOccurred",
455            Self::AgentStop(_) => "AgentStop",
456        }
457    }
458}
459
460/// Callback trait for session hooks — invoked by the CLI at key lifecycle
461/// points (tool use, prompt submission, session start/end, errors).
462///
463/// Implement this trait to intercept and modify CLI behavior at hook points.
464/// There are two styles of implementation — pick whichever fits:
465///
466/// 1. **Per-hook methods (recommended).** Override the specific `on_*` hook
467///    methods you care about; every hook has a default that returns `None`
468///    (meaning "no hook registered, use CLI default behavior").
469/// 2. **Single [`on_hook`](Self::on_hook) method.** Override this one and
470///    `match` on [`HookEvent`] yourself — useful for logging middleware or
471///    shared dispatch logic.
472///
473/// Hooks only fire when hooks are enabled on the session (via
474/// [`SessionConfig::hooks = Some(true)`](crate::types::SessionConfig::hooks),
475/// which [`SessionConfig::with_hooks`](crate::types::SessionConfig::with_hooks)
476/// sets automatically).
477#[async_trait]
478pub trait SessionHooks: Send + Sync + 'static {
479    /// Top-level dispatch. The default implementation fans out to the
480    /// per-hook methods below; override this only if you want a single
481    /// matching point across all hook types.
482    async fn on_hook(&self, event: HookEvent) -> HookOutput {
483        match event {
484            HookEvent::PreToolUse { input, ctx } => self
485                .on_pre_tool_use(input, ctx)
486                .await
487                .map(HookOutput::PreToolUse)
488                .unwrap_or(HookOutput::None),
489            HookEvent::PreMcpToolCall { input, ctx } => self
490                .on_pre_mcp_tool_call(input, ctx)
491                .await
492                .map(HookOutput::PreMcpToolCall)
493                .unwrap_or(HookOutput::None),
494            HookEvent::PostToolUse { input, ctx } => self
495                .on_post_tool_use(input, ctx)
496                .await
497                .map(HookOutput::PostToolUse)
498                .unwrap_or(HookOutput::None),
499            HookEvent::PostToolUseFailure { input, ctx } => self
500                .on_post_tool_use_failure(input, ctx)
501                .await
502                .map(HookOutput::PostToolUseFailure)
503                .unwrap_or(HookOutput::None),
504            HookEvent::UserPromptSubmitted { input, ctx } => self
505                .on_user_prompt_submitted(input, ctx)
506                .await
507                .map(HookOutput::UserPromptSubmitted)
508                .unwrap_or(HookOutput::None),
509            HookEvent::SessionStart { input, ctx } => self
510                .on_session_start(input, ctx)
511                .await
512                .map(HookOutput::SessionStart)
513                .unwrap_or(HookOutput::None),
514            HookEvent::SessionEnd { input, ctx } => self
515                .on_session_end(input, ctx)
516                .await
517                .map(HookOutput::SessionEnd)
518                .unwrap_or(HookOutput::None),
519            HookEvent::ErrorOccurred { input, ctx } => self
520                .on_error_occurred(input, ctx)
521                .await
522                .map(HookOutput::ErrorOccurred)
523                .unwrap_or(HookOutput::None),
524            HookEvent::AgentStop { input, ctx } => self
525                .on_agent_stop(input, ctx)
526                .await
527                .map(HookOutput::AgentStop)
528                .unwrap_or(HookOutput::None),
529        }
530    }
531
532    /// Called before a tool executes. Return `Some(output)` to approve/deny
533    /// or modify the call, or `None` (default) to pass through unchanged.
534    async fn on_pre_tool_use(
535        &self,
536        _input: PreToolUseInput,
537        _ctx: HookContext,
538    ) -> Option<PreToolUseOutput> {
539        None
540    }
541
542    /// Called before an MCP tool call is dispatched. Return `Some(output)` to
543    /// modify or remove request metadata, or `None` (default) to pass through unchanged.
544    async fn on_pre_mcp_tool_call(
545        &self,
546        _input: PreMcpToolCallInput,
547        _ctx: HookContext,
548    ) -> Option<PreMcpToolCallOutput> {
549        None
550    }
551
552    /// Called after a tool executes. Return `Some(output)` to inject
553    /// additional context or signal post-processing decisions; `None`
554    /// (default) means no follow-up.
555    async fn on_post_tool_use(
556        &self,
557        _input: PostToolUseInput,
558        _ctx: HookContext,
559    ) -> Option<PostToolUseOutput> {
560        None
561    }
562
563    /// Called after a tool execution whose result was `"failure"`. The
564    /// success-only [`on_post_tool_use`](Self::on_post_tool_use) hook does
565    /// not fire for these outcomes, so override this method to observe or
566    /// inject extra context after failed tool calls.
567    async fn on_post_tool_use_failure(
568        &self,
569        _input: PostToolUseFailureInput,
570        _ctx: HookContext,
571    ) -> Option<PostToolUseFailureOutput> {
572        None
573    }
574
575    /// Called when the user submits a prompt. Return `Some(output)` to
576    /// rewrite the prompt or inject extra context; `None` (default) passes
577    /// through unchanged.
578    async fn on_user_prompt_submitted(
579        &self,
580        _input: UserPromptSubmittedInput,
581        _ctx: HookContext,
582    ) -> Option<UserPromptSubmittedOutput> {
583        None
584    }
585
586    /// Called at session creation or resume. Return `Some(output)` to
587    /// inject startup context.
588    async fn on_session_start(
589        &self,
590        _input: SessionStartInput,
591        _ctx: HookContext,
592    ) -> Option<SessionStartOutput> {
593        None
594    }
595
596    /// Called when the session ends. Return `Some(output)` if your hook
597    /// needs to signal cleanup behavior.
598    async fn on_session_end(
599        &self,
600        _input: SessionEndInput,
601        _ctx: HookContext,
602    ) -> Option<SessionEndOutput> {
603        None
604    }
605
606    /// Called when the CLI reports an error. Return `Some(output)` to
607    /// influence retry behavior or surface a user-facing notification.
608    async fn on_error_occurred(
609        &self,
610        _input: ErrorOccurredInput,
611        _ctx: HookContext,
612    ) -> Option<ErrorOccurredOutput> {
613        None
614    }
615
616    /// Called when the top-level agent reaches a natural stop. Return a block
617    /// decision to keep the agent running with a follow-up instruction.
618    async fn on_agent_stop(
619        &self,
620        _input: AgentStopInput,
621        _ctx: HookContext,
622    ) -> Option<AgentStopOutput> {
623        None
624    }
625}
626
627/// Dispatches a `hooks.invoke` request to [`SessionHooks::on_hook`].
628///
629/// Returns `Ok(Value)` shaped like `{ "output": ... }` on success.
630/// If no hook is registered ([`HookOutput::None`]), the output is an empty
631/// object: `{ "output": {} }`.
632pub(crate) async fn dispatch_hook(
633    hooks: &dyn SessionHooks,
634    session_id: &SessionId,
635    hook_type: &str,
636    raw_input: Value,
637) -> Result<Value, crate::Error> {
638    let ctx = HookContext {
639        session_id: session_id.clone(),
640    };
641
642    let event = match hook_type {
643        "preToolUse" => {
644            let input: PreToolUseInput = serde_json::from_value(raw_input)?;
645            HookEvent::PreToolUse { input, ctx }
646        }
647        "preMcpToolCall" => {
648            let input: PreMcpToolCallInput = serde_json::from_value(raw_input)?;
649            HookEvent::PreMcpToolCall { input, ctx }
650        }
651        "postToolUse" => {
652            let input: PostToolUseInput = serde_json::from_value(raw_input)?;
653            HookEvent::PostToolUse { input, ctx }
654        }
655        "postToolUseFailure" => {
656            let input: PostToolUseFailureInput = serde_json::from_value(raw_input)?;
657            HookEvent::PostToolUseFailure { input, ctx }
658        }
659        "userPromptSubmitted" => {
660            let input: UserPromptSubmittedInput = serde_json::from_value(raw_input)?;
661            HookEvent::UserPromptSubmitted { input, ctx }
662        }
663        "sessionStart" => {
664            let input: SessionStartInput = serde_json::from_value(raw_input)?;
665            HookEvent::SessionStart { input, ctx }
666        }
667        "sessionEnd" => {
668            let input: SessionEndInput = serde_json::from_value(raw_input)?;
669            HookEvent::SessionEnd { input, ctx }
670        }
671        "errorOccurred" => {
672            let input: ErrorOccurredInput = serde_json::from_value(raw_input)?;
673            HookEvent::ErrorOccurred { input, ctx }
674        }
675        "agentStop" => {
676            let input: AgentStopInput = serde_json::from_value(raw_input)?;
677            HookEvent::AgentStop { input, ctx }
678        }
679        _ => {
680            tracing::warn!(
681                hook_type = hook_type,
682                session_id = %session_id,
683                "unknown hook type"
684            );
685            return Ok(serde_json::json!({ "output": {} }));
686        }
687    };
688
689    let dispatch_start = Instant::now();
690    let output = hooks.on_hook(event).await;
691    tracing::debug!(
692        elapsed_ms = dispatch_start.elapsed().as_millis(),
693        session_id = %session_id,
694        hook_type = hook_type,
695        "SessionHooks::on_hook dispatch"
696    );
697
698    // Validate that the output variant matches the dispatched hook type.
699    // A mismatched return (e.g. HookOutput::SessionEnd for a preToolUse
700    // event) is treated as "no hook registered" to avoid sending the CLI
701    // a semantically wrong response.
702    let output_value = match (hook_type, &output) {
703        (_, HookOutput::None) => None,
704        ("preToolUse", HookOutput::PreToolUse(o)) => Some(serde_json::to_value(o)?),
705        ("preMcpToolCall", HookOutput::PreMcpToolCall(o)) => Some(serde_json::to_value(o)?),
706        ("postToolUse", HookOutput::PostToolUse(o)) => Some(serde_json::to_value(o)?),
707        ("postToolUseFailure", HookOutput::PostToolUseFailure(o)) => Some(serde_json::to_value(o)?),
708        ("userPromptSubmitted", HookOutput::UserPromptSubmitted(o)) => {
709            Some(serde_json::to_value(o)?)
710        }
711        ("sessionStart", HookOutput::SessionStart(o)) => Some(serde_json::to_value(o)?),
712        ("sessionEnd", HookOutput::SessionEnd(o)) => Some(serde_json::to_value(o)?),
713        ("errorOccurred", HookOutput::ErrorOccurred(o)) => Some(serde_json::to_value(o)?),
714        ("agentStop", HookOutput::AgentStop(o)) => Some(serde_json::to_value(o)?),
715        _ => {
716            tracing::warn!(
717                hook_type = hook_type,
718                session_id = %session_id,
719                output_variant = output.variant_name(),
720                "hook returned mismatched output variant, treating as unregistered"
721            );
722            None
723        }
724    };
725
726    Ok(serde_json::json!({ "output": output_value.unwrap_or(Value::Object(Default::default())) }))
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    struct TestHooks;
734
735    #[async_trait]
736    impl SessionHooks for TestHooks {
737        async fn on_hook(&self, event: HookEvent) -> HookOutput {
738            match event {
739                HookEvent::PreToolUse { input, .. } => {
740                    if input.tool_name == "dangerous_tool" {
741                        HookOutput::PreToolUse(PreToolUseOutput {
742                            permission_decision: Some("deny".to_string()),
743                            permission_decision_reason: Some("blocked by policy".to_string()),
744                            ..Default::default()
745                        })
746                    } else {
747                        HookOutput::None
748                    }
749                }
750                HookEvent::UserPromptSubmitted { input, .. } => {
751                    HookOutput::UserPromptSubmitted(UserPromptSubmittedOutput {
752                        modified_prompt: Some(format!("[prefixed] {}", input.prompt)),
753                        ..Default::default()
754                    })
755                }
756                _ => HookOutput::None,
757            }
758        }
759    }
760
761    #[tokio::test]
762    async fn dispatch_pre_tool_use_deny() {
763        let hooks = TestHooks;
764        let input = serde_json::json!({
765            "sessionId": "sess-1",
766            "timestamp": 1234567890,
767            "cwd": "/tmp",
768            "toolName": "dangerous_tool",
769            "toolArgs": {}
770        });
771        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
772            .await
773            .unwrap();
774        let output = &result["output"];
775        assert_eq!(output["permissionDecision"], "deny");
776        assert_eq!(output["permissionDecisionReason"], "blocked by policy");
777    }
778
779    #[tokio::test]
780    async fn dispatch_pre_tool_use_passthrough() {
781        let hooks = TestHooks;
782        let input = serde_json::json!({
783            "sessionId": "sess-1",
784            "timestamp": 1234567890,
785            "cwd": "/tmp",
786            "toolName": "safe_tool",
787            "toolArgs": {"key": "value"}
788        });
789        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
790            .await
791            .unwrap();
792        // No hook registered for this tool — output should be empty object
793        assert_eq!(result["output"], serde_json::json!({}));
794    }
795
796    #[tokio::test]
797    async fn dispatch_user_prompt_submitted() {
798        let hooks = TestHooks;
799        let input = serde_json::json!({
800            "sessionId": "sess-1",
801            "timestamp": 1234567890,
802            "cwd": "/tmp",
803            "prompt": "hello world"
804        });
805        let result = dispatch_hook(
806            &hooks,
807            &SessionId::new("sess-1"),
808            "userPromptSubmitted",
809            input,
810        )
811        .await
812        .unwrap();
813        assert_eq!(result["output"]["modifiedPrompt"], "[prefixed] hello world");
814    }
815
816    #[tokio::test]
817    async fn dispatch_unregistered_hook_returns_empty() {
818        let hooks = TestHooks;
819        let input = serde_json::json!({
820            "sessionId": "sess-1",
821            "timestamp": 1234567890,
822            "cwd": "/tmp",
823            "reason": "complete"
824        });
825        // TestHooks doesn't handle SessionEnd
826        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionEnd", input)
827            .await
828            .unwrap();
829        assert_eq!(result["output"], serde_json::json!({}));
830    }
831
832    #[tokio::test]
833    async fn dispatch_unknown_hook_type() {
834        let hooks = TestHooks;
835        let input = serde_json::json!({});
836        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "unknownHook", input)
837            .await
838            .unwrap();
839        assert_eq!(result["output"], serde_json::json!({}));
840    }
841
842    #[tokio::test]
843    async fn dispatch_mismatched_output_returns_empty() {
844        struct MismatchHooks;
845        #[async_trait]
846        impl SessionHooks for MismatchHooks {
847            async fn on_hook(&self, _event: HookEvent) -> HookOutput {
848                // Always return SessionEnd output regardless of event type
849                HookOutput::SessionEnd(SessionEndOutput {
850                    session_summary: Some("oops".to_string()),
851                    ..Default::default()
852                })
853            }
854        }
855
856        let hooks = MismatchHooks;
857        let input = serde_json::json!({
858            "sessionId": "sess-1",
859            "timestamp": 1234567890,
860            "cwd": "/tmp",
861            "toolName": "some_tool",
862            "toolArgs": {}
863        });
864        // preToolUse event gets a SessionEnd output — should be treated as empty
865        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
866            .await
867            .unwrap();
868        assert_eq!(result["output"], serde_json::json!({}));
869    }
870
871    #[tokio::test]
872    async fn dispatch_post_tool_use_default() {
873        let hooks = TestHooks;
874        let input = serde_json::json!({
875            "sessionId": "sess-1",
876            "timestamp": 1234567890,
877            "cwd": "/tmp",
878            "toolName": "some_tool",
879            "toolArgs": {},
880            "toolResult": "success"
881        });
882        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "postToolUse", input)
883            .await
884            .unwrap();
885        assert_eq!(result["output"], serde_json::json!({}));
886    }
887
888    #[tokio::test]
889    async fn dispatch_post_tool_use_failure_default() {
890        // No handler override — should return an empty output object.
891        let hooks = TestHooks;
892        let input = serde_json::json!({
893            "sessionId": "sess-1",
894            "timestamp": 1234567890,
895            "cwd": "/tmp",
896            "toolName": "some_tool",
897            "toolArgs": {"key": "value"},
898            "error": "boom"
899        });
900        let result = dispatch_hook(
901            &hooks,
902            &SessionId::new("sess-1"),
903            "postToolUseFailure",
904            input,
905        )
906        .await
907        .unwrap();
908        assert_eq!(result["output"], serde_json::json!({}));
909    }
910
911    #[tokio::test]
912    async fn dispatch_post_tool_use_failure_returns_additional_context() {
913        struct FailureHooks;
914        #[async_trait]
915        impl SessionHooks for FailureHooks {
916            async fn on_post_tool_use_failure(
917                &self,
918                input: PostToolUseFailureInput,
919                _ctx: HookContext,
920            ) -> Option<PostToolUseFailureOutput> {
921                assert_eq!(input.session_id, "sess-1");
922                assert_eq!(input.tool_name, "some_tool");
923                assert_eq!(input.error, "boom");
924                assert_eq!(input.working_directory, PathBuf::from("/tmp"));
925                Some(PostToolUseFailureOutput {
926                    additional_context: Some(format!(
927                        "tool {} failed: {}",
928                        input.tool_name, input.error
929                    )),
930                })
931            }
932        }
933
934        let input = serde_json::json!({
935            "sessionId": "sess-1",
936            "timestamp": 1234567890,
937            "cwd": "/tmp",
938            "toolName": "some_tool",
939            "toolArgs": {},
940            "error": "boom"
941        });
942        let result = dispatch_hook(
943            &FailureHooks,
944            &SessionId::new("sess-1"),
945            "postToolUseFailure",
946            input,
947        )
948        .await
949        .unwrap();
950        assert_eq!(
951            result["output"]["additionalContext"],
952            "tool some_tool failed: boom"
953        );
954    }
955
956    #[tokio::test]
957    async fn dispatch_post_tool_use_failure_invalid_input_errors() {
958        // Missing required `error` field — dispatcher should surface the
959        // deserialization error rather than dispatching with empty input.
960        let hooks = TestHooks;
961        let input = serde_json::json!({
962            "sessionId": "sess-1",
963            "timestamp": 1234567890,
964            "cwd": "/tmp",
965            "toolName": "some_tool",
966            "toolArgs": {}
967        });
968        let err = dispatch_hook(
969            &hooks,
970            &SessionId::new("sess-1"),
971            "postToolUseFailure",
972            input,
973        )
974        .await
975        .unwrap_err();
976        let msg = err.to_string().to_ascii_lowercase();
977        assert!(
978            msg.contains("error") || msg.contains("missing field"),
979            "unexpected error: {msg}"
980        );
981    }
982
983    #[tokio::test]
984    async fn dispatch_session_start() {
985        struct StartHooks;
986        #[async_trait]
987        impl SessionHooks for StartHooks {
988            async fn on_hook(&self, event: HookEvent) -> HookOutput {
989                match event {
990                    HookEvent::SessionStart { .. } => {
991                        HookOutput::SessionStart(SessionStartOutput {
992                            additional_context: Some("extra context".to_string()),
993                            ..Default::default()
994                        })
995                    }
996                    _ => HookOutput::None,
997                }
998            }
999        }
1000
1001        let hooks = StartHooks;
1002        let input = serde_json::json!({
1003            "sessionId": "sess-1",
1004            "timestamp": 1234567890,
1005            "cwd": "/tmp",
1006            "source": "new"
1007        });
1008        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionStart", input)
1009            .await
1010            .unwrap();
1011        assert_eq!(result["output"]["additionalContext"], "extra context");
1012    }
1013
1014    #[tokio::test]
1015    async fn dispatch_error_occurred() {
1016        struct ErrorHooks;
1017        #[async_trait]
1018        impl SessionHooks for ErrorHooks {
1019            async fn on_hook(&self, event: HookEvent) -> HookOutput {
1020                match event {
1021                    HookEvent::ErrorOccurred { .. } => {
1022                        HookOutput::ErrorOccurred(ErrorOccurredOutput {
1023                            error_handling: Some("retry".to_string()),
1024                            retry_count: Some(3),
1025                            ..Default::default()
1026                        })
1027                    }
1028                    _ => HookOutput::None,
1029                }
1030            }
1031        }
1032
1033        let hooks = ErrorHooks;
1034        let input = serde_json::json!({
1035            "sessionId": "sess-1",
1036            "timestamp": 1234567890,
1037            "cwd": "/tmp",
1038            "error": "model timeout",
1039            "errorContext": "model_call",
1040            "recoverable": true
1041        });
1042        let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "errorOccurred", input)
1043            .await
1044            .unwrap();
1045        assert_eq!(result["output"]["errorHandling"], "retry");
1046        assert_eq!(result["output"]["retryCount"], 3);
1047    }
1048
1049    #[tokio::test]
1050    async fn dispatch_agent_stop_block() {
1051        struct AgentStopHooks;
1052        #[async_trait]
1053        impl SessionHooks for AgentStopHooks {
1054            async fn on_agent_stop(
1055                &self,
1056                input: AgentStopInput,
1057                ctx: HookContext,
1058            ) -> Option<AgentStopOutput> {
1059                assert_eq!(ctx.session_id, SessionId::new("sess-1"));
1060                assert_eq!(input.session_id, "sess-1");
1061                assert_eq!(input.stop_reason.as_deref(), Some("end_turn"));
1062                assert_eq!(
1063                    input.transcript_path,
1064                    Some(PathBuf::from("/tmp/transcript.jsonl"))
1065                );
1066                assert_eq!(input.stop_hook_active, Some(true));
1067                Some(AgentStopOutput {
1068                    decision: Some("block".to_string()),
1069                    reason: Some("finish the remaining work".to_string()),
1070                })
1071            }
1072        }
1073
1074        let input = serde_json::json!({
1075            "sessionId": "sess-1",
1076            "timestamp": 1234567890,
1077            "cwd": "/tmp",
1078            "stopReason": "end_turn",
1079            "transcriptPath": "/tmp/transcript.jsonl",
1080            "stop_hook_active": true
1081        });
1082        let result = dispatch_hook(
1083            &AgentStopHooks,
1084            &SessionId::new("sess-1"),
1085            "agentStop",
1086            input,
1087        )
1088        .await
1089        .unwrap();
1090
1091        assert_eq!(result["output"]["decision"], "block");
1092        assert_eq!(result["output"]["reason"], "finish the remaining work");
1093    }
1094}