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