//! Lifecycle hook callbacks invoked at key session points. //! //! Hooks let you intercept and modify CLI behavior — approve or deny tool //! use, rewrite user prompts, inject context at session start, and handle //! errors. Implement [`SessionHooks`](crate::hooks::SessionHooks) and pass it to //! [`Client::create_session`](crate::Client::create_session). use std::path::PathBuf; use std::time::Instant; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::types::SessionId; /// Context provided to every hook invocation. #[derive(Debug, Clone)] pub struct HookContext { /// The session this hook was triggered in. pub session_id: SessionId, } /// Input for the `preToolUse` hook — received before a tool executes. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PreToolUseInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// Name of the tool about to execute. pub tool_name: String, /// Arguments passed to the tool. pub tool_args: Value, } /// Output for the `preToolUse` hook. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct PreToolUseOutput { /// "allow" or "deny". #[serde(skip_serializing_if = "Option::is_none")] pub permission_decision: Option, /// Reason for the decision (shown to the agent). #[serde(skip_serializing_if = "Option::is_none")] pub permission_decision_reason: Option, /// Replacement arguments for the tool. #[serde(skip_serializing_if = "Option::is_none")] pub modified_args: Option, /// Extra context injected into the agent's prompt. #[serde(skip_serializing_if = "Option::is_none")] pub additional_context: Option, /// Suppress the hook's output from the session log. #[serde(skip_serializing_if = "Option::is_none")] pub suppress_output: Option, } /// Input for the `preMcpToolCall` hook — received before an MCP tool call is dispatched. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PreMcpToolCallInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// Name of the MCP server being called. pub server_name: String, /// Name of the MCP tool being called. pub tool_name: String, /// Arguments for the MCP tool call. pub arguments: Value, /// Tool call ID, if available. #[serde(default)] pub tool_call_id: Option, /// MCP request metadata. #[serde(default, rename = "_meta")] pub meta: Option, } /// Output for the `preMcpToolCall` hook. /// /// `meta_to_use` has tri-state semantics: /// - `None`: field is absent in JSON, meaning preserve existing `_meta` /// - `Some(Value::Null)`: serialized as JSON `null`, meaning omit `_meta` /// - `Some(Value::Object(...))`: serialized as JSON object, meaning replace `_meta` #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct PreMcpToolCallOutput { /// Hook-controlled metadata for the outgoing MCP request. #[serde(skip_serializing_if = "Option::is_none")] pub meta_to_use: Option, } /// Input for the `postToolUse` hook — received after a tool executes. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PostToolUseInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// Name of the tool that executed. pub tool_name: String, /// Arguments that were passed to the tool. pub tool_args: Value, /// Result returned by the tool. pub tool_result: Value, } /// Output for the `postToolUse` hook. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct PostToolUseOutput { /// Replacement result for the tool. #[serde(skip_serializing_if = "Option::is_none")] pub modified_result: Option, /// Extra context injected into the agent's prompt. #[serde(skip_serializing_if = "Option::is_none")] pub additional_context: Option, /// Suppress the hook's output from the session log. #[serde(skip_serializing_if = "Option::is_none")] pub suppress_output: Option, } /// Input for the `postToolUseFailure` hook — received after a tool execution /// whose result was `"failure"`. /// /// `postToolUse` only fires for successful tool executions. Register a handler /// for `postToolUseFailure` to observe failed tool calls. The CLI extracts the /// failure message from the tool result and passes it as the `error` field /// (rather than passing the full result object). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PostToolUseFailureInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// Name of the tool that failed. pub tool_name: String, /// Arguments that were passed to the tool. pub tool_args: Value, /// Failure message extracted from the tool's result. pub error: String, } /// Output for the `postToolUseFailure` hook. /// /// Only `additional_context` is consumed by the host CLI — it is appended as /// hidden guidance to the model alongside the failed tool result. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct PostToolUseFailureOutput { /// Extra context appended to the failed tool result for the agent. #[serde(skip_serializing_if = "Option::is_none")] pub additional_context: Option, } /// Input for the `userPromptSubmitted` hook — received when the user sends a message. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserPromptSubmittedInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// The user's message text. pub prompt: String, } /// Output for the `userPromptSubmitted` hook. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct UserPromptSubmittedOutput { /// Replacement prompt text. #[serde(skip_serializing_if = "Option::is_none")] pub modified_prompt: Option, /// Extra context injected into the agent's prompt. #[serde(skip_serializing_if = "Option::is_none")] pub additional_context: Option, /// Suppress the hook's output from the session log. #[serde(skip_serializing_if = "Option::is_none")] pub suppress_output: Option, } /// Input for the `sessionStart` hook. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionStartInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// How the session was started: `"startup"`, `"resume"`, or `"new"`. pub source: String, /// The first user message, if any. #[serde(default)] pub initial_prompt: Option, } /// Output for the `sessionStart` hook. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionStartOutput { /// Extra context injected at session start. #[serde(skip_serializing_if = "Option::is_none")] pub additional_context: Option, /// Config overrides applied to the session. #[serde(skip_serializing_if = "Option::is_none")] pub modified_config: Option, } /// Input for the `sessionEnd` hook. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionEndInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// Why the session ended: `"complete"`, `"error"`, `"abort"`, `"timeout"`, `"user_exit"`. pub reason: String, /// The last assistant message. #[serde(default)] pub final_message: Option, /// Error message, if the session ended due to an error. #[serde(default)] pub error: Option, } /// Output for the `sessionEnd` hook. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionEndOutput { /// Suppress the hook's output from the session log. #[serde(skip_serializing_if = "Option::is_none")] pub suppress_output: Option, /// Actions to run during cleanup. #[serde(skip_serializing_if = "Option::is_none")] pub cleanup_actions: Option>, /// Summary text for the session. #[serde(skip_serializing_if = "Option::is_none")] pub session_summary: Option, } /// Input for the `errorOccurred` hook. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ErrorOccurredInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, /// The error message. pub error: String, /// Context where the error occurred: `"model_call"`, `"tool_execution"`, `"system"`, `"user_input"`. pub error_context: String, /// Whether the error is recoverable. pub recoverable: bool, } /// Output for the `errorOccurred` hook. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct ErrorOccurredOutput { /// Suppress the hook's output from the session log. #[serde(skip_serializing_if = "Option::is_none")] pub suppress_output: Option, /// How to handle the error: `"retry"`, `"skip"`, or `"abort"`. #[serde(skip_serializing_if = "Option::is_none")] pub error_handling: Option, /// Number of retries to attempt. #[serde(skip_serializing_if = "Option::is_none")] pub retry_count: Option, /// Message to show the user. #[serde(skip_serializing_if = "Option::is_none")] pub user_notification: Option, } /// Events dispatched to [`SessionHooks::on_hook`] at CLI lifecycle points. /// /// Each variant carries the typed input for that hook plus the shared /// [`HookContext`]. The handler returns a matching [`HookOutput`] variant /// (or [`HookOutput::None`] to signal "no hook registered"). #[non_exhaustive] #[derive(Debug)] pub enum HookEvent { /// Fired before a tool executes. PreToolUse { /// Typed input data. input: PreToolUseInput, /// Session context. ctx: HookContext, }, /// Fired before an MCP tool call is dispatched. PreMcpToolCall { /// Typed input data. input: PreMcpToolCallInput, /// Session context. ctx: HookContext, }, /// Fired after a tool executes. PostToolUse { /// Typed input data. input: PostToolUseInput, /// Session context. ctx: HookContext, }, /// Fired after a tool execution whose result was `"failure"`. /// [`HookEvent::PostToolUse`] only fires on success, so observe this /// variant to react to failed tool calls. PostToolUseFailure { /// Typed input data. input: PostToolUseFailureInput, /// Session context. ctx: HookContext, }, /// Fired when the user sends a message. UserPromptSubmitted { /// Typed input data. input: UserPromptSubmittedInput, /// Session context. ctx: HookContext, }, /// Fired at session creation or resume. SessionStart { /// Typed input data. input: SessionStartInput, /// Session context. ctx: HookContext, }, /// Fired when the session ends. SessionEnd { /// Typed input data. input: SessionEndInput, /// Session context. ctx: HookContext, }, /// Fired when an error occurs. ErrorOccurred { /// Typed input data. input: ErrorOccurredInput, /// Session context. ctx: HookContext, }, } /// Response from [`SessionHooks::on_hook`] back to the SDK. /// /// Return the variant matching the [`HookEvent`] you received, or /// [`HookOutput::None`] to indicate no hook is registered for that event. #[non_exhaustive] #[derive(Debug)] pub enum HookOutput { /// No hook registered — the SDK returns an empty output object to the CLI. None, /// Response for a pre-tool-use hook. PreToolUse(PreToolUseOutput), /// Response for a pre-MCP-tool-call hook. PreMcpToolCall(PreMcpToolCallOutput), /// Response for a post-tool-use hook. PostToolUse(PostToolUseOutput), /// Response for a post-tool-use-failure hook. PostToolUseFailure(PostToolUseFailureOutput), /// Response for a user-prompt-submitted hook. UserPromptSubmitted(UserPromptSubmittedOutput), /// Response for a session-start hook. SessionStart(SessionStartOutput), /// Response for a session-end hook. SessionEnd(SessionEndOutput), /// Response for an error-occurred hook. ErrorOccurred(ErrorOccurredOutput), } impl HookOutput { fn variant_name(&self) -> &'static str { match self { Self::None => "None", Self::PreToolUse(_) => "PreToolUse", Self::PreMcpToolCall(_) => "PreMcpToolCall", Self::PostToolUse(_) => "PostToolUse", Self::PostToolUseFailure(_) => "PostToolUseFailure", Self::UserPromptSubmitted(_) => "UserPromptSubmitted", Self::SessionStart(_) => "SessionStart", Self::SessionEnd(_) => "SessionEnd", Self::ErrorOccurred(_) => "ErrorOccurred", } } } /// Callback trait for session hooks — invoked by the CLI at key lifecycle /// points (tool use, prompt submission, session start/end, errors). /// /// Implement this trait to intercept and modify CLI behavior at hook points. /// There are two styles of implementation — pick whichever fits: /// /// 1. **Per-hook methods (recommended).** Override the specific `on_*` hook /// methods you care about; every hook has a default that returns `None` /// (meaning "no hook registered, use CLI default behavior"). /// 2. **Single [`on_hook`](Self::on_hook) method.** Override this one and /// `match` on [`HookEvent`] yourself — useful for logging middleware or /// shared dispatch logic. /// /// Hooks only fire when hooks are enabled on the session (via /// [`SessionConfig::hooks = Some(true)`](crate::types::SessionConfig::hooks), /// which [`SessionConfig::with_hooks`](crate::types::SessionConfig::with_hooks) /// sets automatically). #[async_trait] pub trait SessionHooks: Send + Sync + 'static { /// Top-level dispatch. The default implementation fans out to the /// per-hook methods below; override this only if you want a single /// matching point across all hook types. async fn on_hook(&self, event: HookEvent) -> HookOutput { match event { HookEvent::PreToolUse { input, ctx } => self .on_pre_tool_use(input, ctx) .await .map(HookOutput::PreToolUse) .unwrap_or(HookOutput::None), HookEvent::PreMcpToolCall { input, ctx } => self .on_pre_mcp_tool_call(input, ctx) .await .map(HookOutput::PreMcpToolCall) .unwrap_or(HookOutput::None), HookEvent::PostToolUse { input, ctx } => self .on_post_tool_use(input, ctx) .await .map(HookOutput::PostToolUse) .unwrap_or(HookOutput::None), HookEvent::PostToolUseFailure { input, ctx } => self .on_post_tool_use_failure(input, ctx) .await .map(HookOutput::PostToolUseFailure) .unwrap_or(HookOutput::None), HookEvent::UserPromptSubmitted { input, ctx } => self .on_user_prompt_submitted(input, ctx) .await .map(HookOutput::UserPromptSubmitted) .unwrap_or(HookOutput::None), HookEvent::SessionStart { input, ctx } => self .on_session_start(input, ctx) .await .map(HookOutput::SessionStart) .unwrap_or(HookOutput::None), HookEvent::SessionEnd { input, ctx } => self .on_session_end(input, ctx) .await .map(HookOutput::SessionEnd) .unwrap_or(HookOutput::None), HookEvent::ErrorOccurred { input, ctx } => self .on_error_occurred(input, ctx) .await .map(HookOutput::ErrorOccurred) .unwrap_or(HookOutput::None), } } /// Called before a tool executes. Return `Some(output)` to approve/deny /// or modify the call, or `None` (default) to pass through unchanged. async fn on_pre_tool_use( &self, _input: PreToolUseInput, _ctx: HookContext, ) -> Option { None } /// Called before an MCP tool call is dispatched. Return `Some(output)` to /// modify or remove request metadata, or `None` (default) to pass through unchanged. async fn on_pre_mcp_tool_call( &self, _input: PreMcpToolCallInput, _ctx: HookContext, ) -> Option { None } /// Called after a tool executes. Return `Some(output)` to inject /// additional context or signal post-processing decisions; `None` /// (default) means no follow-up. async fn on_post_tool_use( &self, _input: PostToolUseInput, _ctx: HookContext, ) -> Option { None } /// Called after a tool execution whose result was `"failure"`. The /// success-only [`on_post_tool_use`](Self::on_post_tool_use) hook does /// not fire for these outcomes, so override this method to observe or /// inject extra context after failed tool calls. async fn on_post_tool_use_failure( &self, _input: PostToolUseFailureInput, _ctx: HookContext, ) -> Option { None } /// Called when the user submits a prompt. Return `Some(output)` to /// rewrite the prompt or inject extra context; `None` (default) passes /// through unchanged. async fn on_user_prompt_submitted( &self, _input: UserPromptSubmittedInput, _ctx: HookContext, ) -> Option { None } /// Called at session creation or resume. Return `Some(output)` to /// inject startup context. async fn on_session_start( &self, _input: SessionStartInput, _ctx: HookContext, ) -> Option { None } /// Called when the session ends. Return `Some(output)` if your hook /// needs to signal cleanup behavior. async fn on_session_end( &self, _input: SessionEndInput, _ctx: HookContext, ) -> Option { None } /// Called when the CLI reports an error. Return `Some(output)` to /// influence retry behavior or surface a user-facing notification. async fn on_error_occurred( &self, _input: ErrorOccurredInput, _ctx: HookContext, ) -> Option { None } } /// Dispatches a `hooks.invoke` request to [`SessionHooks::on_hook`]. /// /// Returns `Ok(Value)` shaped like `{ "output": ... }` on success. /// If no hook is registered ([`HookOutput::None`]), the output is an empty /// object: `{ "output": {} }`. pub(crate) async fn dispatch_hook( hooks: &dyn SessionHooks, session_id: &SessionId, hook_type: &str, raw_input: Value, ) -> Result { let ctx = HookContext { session_id: session_id.clone(), }; let event = match hook_type { "preToolUse" => { let input: PreToolUseInput = serde_json::from_value(raw_input)?; HookEvent::PreToolUse { input, ctx } } "preMcpToolCall" => { let input: PreMcpToolCallInput = serde_json::from_value(raw_input)?; HookEvent::PreMcpToolCall { input, ctx } } "postToolUse" => { let input: PostToolUseInput = serde_json::from_value(raw_input)?; HookEvent::PostToolUse { input, ctx } } "postToolUseFailure" => { let input: PostToolUseFailureInput = serde_json::from_value(raw_input)?; HookEvent::PostToolUseFailure { input, ctx } } "userPromptSubmitted" => { let input: UserPromptSubmittedInput = serde_json::from_value(raw_input)?; HookEvent::UserPromptSubmitted { input, ctx } } "sessionStart" => { let input: SessionStartInput = serde_json::from_value(raw_input)?; HookEvent::SessionStart { input, ctx } } "sessionEnd" => { let input: SessionEndInput = serde_json::from_value(raw_input)?; HookEvent::SessionEnd { input, ctx } } "errorOccurred" => { let input: ErrorOccurredInput = serde_json::from_value(raw_input)?; HookEvent::ErrorOccurred { input, ctx } } _ => { tracing::warn!( hook_type = hook_type, session_id = %session_id, "unknown hook type" ); return Ok(serde_json::json!({ "output": {} })); } }; let dispatch_start = Instant::now(); let output = hooks.on_hook(event).await; tracing::debug!( elapsed_ms = dispatch_start.elapsed().as_millis(), session_id = %session_id, hook_type = hook_type, "SessionHooks::on_hook dispatch" ); // Validate that the output variant matches the dispatched hook type. // A mismatched return (e.g. HookOutput::SessionEnd for a preToolUse // event) is treated as "no hook registered" to avoid sending the CLI // a semantically wrong response. let output_value = match (hook_type, &output) { (_, HookOutput::None) => None, ("preToolUse", HookOutput::PreToolUse(o)) => Some(serde_json::to_value(o)?), ("preMcpToolCall", HookOutput::PreMcpToolCall(o)) => Some(serde_json::to_value(o)?), ("postToolUse", HookOutput::PostToolUse(o)) => Some(serde_json::to_value(o)?), ("postToolUseFailure", HookOutput::PostToolUseFailure(o)) => Some(serde_json::to_value(o)?), ("userPromptSubmitted", HookOutput::UserPromptSubmitted(o)) => { Some(serde_json::to_value(o)?) } ("sessionStart", HookOutput::SessionStart(o)) => Some(serde_json::to_value(o)?), ("sessionEnd", HookOutput::SessionEnd(o)) => Some(serde_json::to_value(o)?), ("errorOccurred", HookOutput::ErrorOccurred(o)) => Some(serde_json::to_value(o)?), _ => { tracing::warn!( hook_type = hook_type, session_id = %session_id, output_variant = output.variant_name(), "hook returned mismatched output variant, treating as unregistered" ); None } }; Ok(serde_json::json!({ "output": output_value.unwrap_or(Value::Object(Default::default())) })) } #[cfg(test)] mod tests { use super::*; struct TestHooks; #[async_trait] impl SessionHooks for TestHooks { async fn on_hook(&self, event: HookEvent) -> HookOutput { match event { HookEvent::PreToolUse { input, .. } => { if input.tool_name == "dangerous_tool" { HookOutput::PreToolUse(PreToolUseOutput { permission_decision: Some("deny".to_string()), permission_decision_reason: Some("blocked by policy".to_string()), ..Default::default() }) } else { HookOutput::None } } HookEvent::UserPromptSubmitted { input, .. } => { HookOutput::UserPromptSubmitted(UserPromptSubmittedOutput { modified_prompt: Some(format!("[prefixed] {}", input.prompt)), ..Default::default() }) } _ => HookOutput::None, } } } #[tokio::test] async fn dispatch_pre_tool_use_deny() { let hooks = TestHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "dangerous_tool", "toolArgs": {} }); let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input) .await .unwrap(); let output = &result["output"]; assert_eq!(output["permissionDecision"], "deny"); assert_eq!(output["permissionDecisionReason"], "blocked by policy"); } #[tokio::test] async fn dispatch_pre_tool_use_passthrough() { let hooks = TestHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "safe_tool", "toolArgs": {"key": "value"} }); let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input) .await .unwrap(); // No hook registered for this tool — output should be empty object assert_eq!(result["output"], serde_json::json!({})); } #[tokio::test] async fn dispatch_user_prompt_submitted() { let hooks = TestHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "prompt": "hello world" }); let result = dispatch_hook( &hooks, &SessionId::new("sess-1"), "userPromptSubmitted", input, ) .await .unwrap(); assert_eq!(result["output"]["modifiedPrompt"], "[prefixed] hello world"); } #[tokio::test] async fn dispatch_unregistered_hook_returns_empty() { let hooks = TestHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "reason": "complete" }); // TestHooks doesn't handle SessionEnd let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionEnd", input) .await .unwrap(); assert_eq!(result["output"], serde_json::json!({})); } #[tokio::test] async fn dispatch_unknown_hook_type() { let hooks = TestHooks; let input = serde_json::json!({}); let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "unknownHook", input) .await .unwrap(); assert_eq!(result["output"], serde_json::json!({})); } #[tokio::test] async fn dispatch_mismatched_output_returns_empty() { struct MismatchHooks; #[async_trait] impl SessionHooks for MismatchHooks { async fn on_hook(&self, _event: HookEvent) -> HookOutput { // Always return SessionEnd output regardless of event type HookOutput::SessionEnd(SessionEndOutput { session_summary: Some("oops".to_string()), ..Default::default() }) } } let hooks = MismatchHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "some_tool", "toolArgs": {} }); // preToolUse event gets a SessionEnd output — should be treated as empty let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input) .await .unwrap(); assert_eq!(result["output"], serde_json::json!({})); } #[tokio::test] async fn dispatch_post_tool_use_default() { let hooks = TestHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "some_tool", "toolArgs": {}, "toolResult": "success" }); let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "postToolUse", input) .await .unwrap(); assert_eq!(result["output"], serde_json::json!({})); } #[tokio::test] async fn dispatch_post_tool_use_failure_default() { // No handler override — should return an empty output object. let hooks = TestHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "some_tool", "toolArgs": {"key": "value"}, "error": "boom" }); let result = dispatch_hook( &hooks, &SessionId::new("sess-1"), "postToolUseFailure", input, ) .await .unwrap(); assert_eq!(result["output"], serde_json::json!({})); } #[tokio::test] async fn dispatch_post_tool_use_failure_returns_additional_context() { struct FailureHooks; #[async_trait] impl SessionHooks for FailureHooks { async fn on_post_tool_use_failure( &self, input: PostToolUseFailureInput, _ctx: HookContext, ) -> Option { assert_eq!(input.session_id, "sess-1"); assert_eq!(input.tool_name, "some_tool"); assert_eq!(input.error, "boom"); assert_eq!(input.working_directory, PathBuf::from("/tmp")); Some(PostToolUseFailureOutput { additional_context: Some(format!( "tool {} failed: {}", input.tool_name, input.error )), }) } } let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "some_tool", "toolArgs": {}, "error": "boom" }); let result = dispatch_hook( &FailureHooks, &SessionId::new("sess-1"), "postToolUseFailure", input, ) .await .unwrap(); assert_eq!( result["output"]["additionalContext"], "tool some_tool failed: boom" ); } #[tokio::test] async fn dispatch_post_tool_use_failure_invalid_input_errors() { // Missing required `error` field — dispatcher should surface the // deserialization error rather than dispatching with empty input. let hooks = TestHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "some_tool", "toolArgs": {} }); let err = dispatch_hook( &hooks, &SessionId::new("sess-1"), "postToolUseFailure", input, ) .await .unwrap_err(); let msg = err.to_string().to_ascii_lowercase(); assert!( msg.contains("error") || msg.contains("missing field"), "unexpected error: {msg}" ); } #[tokio::test] async fn dispatch_session_start() { struct StartHooks; #[async_trait] impl SessionHooks for StartHooks { async fn on_hook(&self, event: HookEvent) -> HookOutput { match event { HookEvent::SessionStart { .. } => { HookOutput::SessionStart(SessionStartOutput { additional_context: Some("extra context".to_string()), ..Default::default() }) } _ => HookOutput::None, } } } let hooks = StartHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "source": "new" }); let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionStart", input) .await .unwrap(); assert_eq!(result["output"]["additionalContext"], "extra context"); } #[tokio::test] async fn dispatch_error_occurred() { struct ErrorHooks; #[async_trait] impl SessionHooks for ErrorHooks { async fn on_hook(&self, event: HookEvent) -> HookOutput { match event { HookEvent::ErrorOccurred { .. } => { HookOutput::ErrorOccurred(ErrorOccurredOutput { error_handling: Some("retry".to_string()), retry_count: Some(3), ..Default::default() }) } _ => HookOutput::None, } } } let hooks = ErrorHooks; let input = serde_json::json!({ "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "error": "model timeout", "errorContext": "model_call", "recoverable": true }); let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "errorOccurred", input) .await .unwrap(); assert_eq!(result["output"]["errorHandling"], "retry"); assert_eq!(result["output"]["retryCount"], 3); } }