From 5883a907e502137be796fc0da35841b4e74e6e4c Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:33:28 -0400 Subject: [PATCH 01/64] feat(hooks): add user-configurable hook system with config, executor, and handler --- .../src/hooks/user_hook_config_loader.rs | 208 +++++++ .../forge_app/src/hooks/user_hook_executor.rs | 258 +++++++++ .../forge_app/src/hooks/user_hook_handler.rs | 531 ++++++++++++++++++ crates/forge_domain/src/user_hook_config.rs | 311 ++++++++++ crates/forge_domain/src/user_hook_io.rs | 313 +++++++++++ 5 files changed, 1621 insertions(+) create mode 100644 crates/forge_app/src/hooks/user_hook_config_loader.rs create mode 100644 crates/forge_app/src/hooks/user_hook_executor.rs create mode 100644 crates/forge_app/src/hooks/user_hook_handler.rs create mode 100644 crates/forge_domain/src/user_hook_config.rs create mode 100644 crates/forge_domain/src/user_hook_io.rs diff --git a/crates/forge_app/src/hooks/user_hook_config_loader.rs b/crates/forge_app/src/hooks/user_hook_config_loader.rs new file mode 100644 index 0000000000..7c24b70e05 --- /dev/null +++ b/crates/forge_app/src/hooks/user_hook_config_loader.rs @@ -0,0 +1,208 @@ +use std::path::Path; + +use forge_domain::{UserHookConfig, UserSettings}; +use tracing::{debug, warn}; + +/// Loads and merges user hook configurations from the three settings file +/// locations. +/// +/// Resolution order (all merged, not overridden): +/// 1. `~/.forge/settings.json` (user-level, applies to all projects) +/// 2. `.forge/settings.json` (project-level, committable) +/// 3. `.forge/settings.local.json` (project-level, gitignored) +pub struct UserHookConfigLoader; + +impl UserHookConfigLoader { + /// Loads and merges hook configurations from all settings files. + /// + /// # Arguments + /// * `home` - Home directory (e.g., `/Users/name`). If `None`, user-level + /// settings are skipped. + /// * `cwd` - Current working directory for project-level settings. + /// + /// # Errors + /// This function does not return errors. Invalid or missing files are + /// silently skipped with a debug log. + pub fn load(home: Option<&Path>, cwd: &Path) -> UserHookConfig { + let mut config = UserHookConfig::new(); + + // 1. User-level: ~/.forge/settings.json + if let Some(home) = home { + let user_settings_path = home.join("forge").join("settings.json"); + if let Some(user_config) = Self::load_file(&user_settings_path) { + debug!(path = %user_settings_path.display(), "Loaded user-level hook config"); + config.merge(user_config); + } + } + + // 2. Project-level: .forge/settings.json + let project_settings_path = cwd.join(".forge").join("settings.json"); + if let Some(project_config) = Self::load_file(&project_settings_path) { + debug!(path = %project_settings_path.display(), "Loaded project-level hook config"); + config.merge(project_config); + } + + // 3. Project-local: .forge/settings.local.json + let local_settings_path = cwd.join(".forge").join("settings.local.json"); + if let Some(local_config) = Self::load_file(&local_settings_path) { + debug!(path = %local_settings_path.display(), "Loaded project-local hook config"); + config.merge(local_config); + } + + if !config.is_empty() { + debug!( + event_count = config.events.len(), + "Merged user hook configuration" + ); + } + + config + } + + /// Loads a single settings file and extracts hook configuration. + /// + /// Returns `None` if the file doesn't exist or is invalid. + fn load_file(path: &Path) -> Option { + let contents = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return None, + }; + + match serde_json::from_str::(&contents) { + Ok(settings) => { + if settings.hooks.is_empty() { + None + } else { + Some(settings.hooks) + } + } + Err(e) => { + warn!( + path = %path.display(), + error = %e, + "Failed to parse settings file for hooks" + ); + None + } + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_load_nonexistent_paths() { + let home = PathBuf::from("/nonexistent/home"); + let cwd = PathBuf::from("/nonexistent/project"); + + let actual = UserHookConfigLoader::load(Some(&home), &cwd); + assert!(actual.is_empty()); + } + + #[test] + fn test_load_file_valid_settings() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } + ] + } + }"#, + ) + .unwrap(); + + let actual = UserHookConfigLoader::load_file(&settings_path); + assert!(actual.is_some()); + let config = actual.unwrap(); + assert_eq!( + config + .get_groups(&forge_domain::UserHookEventName::PreToolUse) + .len(), + 1 + ); + } + + #[test] + fn test_load_file_settings_without_hooks() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write(&settings_path, r#"{"other_key": "value"}"#).unwrap(); + + let actual = UserHookConfigLoader::load_file(&settings_path); + assert!(actual.is_none()); + } + + #[test] + fn test_load_merges_all_sources() { + // Set up a fake home directory + let home_dir = tempfile::tempdir().unwrap(); + let forge_dir = home_dir.path().join("forge"); + std::fs::create_dir_all(&forge_dir).unwrap(); + std::fs::write( + forge_dir.join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "global.sh" }] } + ] + } + }"#, + ) + .unwrap(); + + // Set up a project directory + let project_dir = tempfile::tempdir().unwrap(); + let project_forge_dir = project_dir.path().join(".forge"); + std::fs::create_dir_all(&project_forge_dir).unwrap(); + std::fs::write( + project_forge_dir.join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { "matcher": "Write", "hooks": [{ "type": "command", "command": "project.sh" }] } + ] + } + }"#, + ) + .unwrap(); + std::fs::write( + project_forge_dir.join("settings.local.json"), + r#"{ + "hooks": { + "Stop": [ + { "hooks": [{ "type": "command", "command": "local-stop.sh" }] } + ] + } + }"#, + ) + .unwrap(); + + let actual = + UserHookConfigLoader::load(Some(home_dir.path()), project_dir.path()); + + // PreToolUse should have 2 groups (global + project) + assert_eq!( + actual + .get_groups(&forge_domain::UserHookEventName::PreToolUse) + .len(), + 2 + ); + // Stop should have 1 group (local) + assert_eq!( + actual + .get_groups(&forge_domain::UserHookEventName::Stop) + .len(), + 1 + ); + } +} diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs new file mode 100644 index 0000000000..2faf677d3c --- /dev/null +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use forge_domain::HookExecutionResult; +use tokio::io::AsyncWriteExt; +use tracing::{debug, warn}; + +/// Default timeout for hook commands (10 minutes). +const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(600); + +/// Executes user hook shell commands with stdin piping and timeout support. +/// +/// Uses `tokio::process::Command` directly (not `CommandInfra`) because we +/// need stdin piping which the existing infrastructure doesn't support. +pub struct UserHookExecutor; + +impl UserHookExecutor { + /// Executes a shell command, piping `input_json` to stdin and capturing + /// stdout/stderr. + /// + /// # Arguments + /// * `command` - The shell command string to execute. + /// * `input_json` - JSON string to pipe to the command's stdin. + /// * `timeout` - Optional timeout in milliseconds. Uses default (10 min) if + /// `None`. + /// * `cwd` - Working directory for the command. + /// * `env_vars` - Additional environment variables to set. + /// + /// # Errors + /// Returns an error if the process cannot be spawned. + pub async fn execute( + command: &str, + input_json: &str, + timeout: Option, + cwd: &PathBuf, + env_vars: &HashMap, + ) -> anyhow::Result { + let timeout_duration = timeout + .map(Duration::from_millis) + .unwrap_or(DEFAULT_HOOK_TIMEOUT); + + debug!( + command = command, + cwd = %cwd.display(), + timeout_ms = timeout_duration.as_millis() as u64, + "Executing user hook command" + ); + + let shell = if cfg!(target_os = "windows") { + "cmd" + } else { + "sh" + }; + let shell_arg = if cfg!(target_os = "windows") { + "/C" + } else { + "-c" + }; + + let mut child = tokio::process::Command::new(shell) + .arg(shell_arg) + .arg(command) + .current_dir(cwd) + .envs(env_vars) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + // Pipe JSON input to stdin + if let Some(mut stdin) = child.stdin.take() { + let input = input_json.to_string(); + tokio::spawn(async move { + let _ = stdin.write_all(input.as_bytes()).await; + let _ = stdin.shutdown().await; + }); + } + + // Wait for the command with timeout. + // Note: `wait_with_output()` takes ownership of `child`. On timeout, + // the future is dropped, and tokio will clean up the child process. + let result = tokio::time::timeout(timeout_duration, child.wait_with_output()).await; + + match result { + Ok(Ok(output)) => { + let exit_code = output.status.code(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + debug!( + command = command, + exit_code = ?exit_code, + stdout_len = stdout.len(), + stderr_len = stderr.len(), + "Hook command completed" + ); + + Ok(HookExecutionResult { exit_code, stdout, stderr }) + } + Ok(Err(e)) => { + warn!(command = command, error = %e, "Hook command failed to execute"); + Err(e.into()) + } + Err(_) => { + warn!( + command = command, + timeout_ms = timeout_duration.as_millis() as u64, + "Hook command timed out" + ); + // Process is already consumed by wait_with_output, tokio + // handles cleanup when the future is dropped. + Ok(HookExecutionResult { + exit_code: None, + stdout: String::new(), + stderr: format!( + "Hook command timed out after {}ms", + timeout_duration.as_millis() + ), + }) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use pretty_assertions::assert_eq; + + use super::*; + + #[tokio::test] + async fn test_execute_simple_command() { + let cwd = std::env::current_dir().unwrap(); + let actual = + UserHookExecutor::execute("echo hello", "{}", None, &cwd, &HashMap::new()) + .await + .unwrap(); + + assert_eq!(actual.exit_code, Some(0)); + assert_eq!(actual.stdout.trim(), "hello"); + assert!(actual.is_success()); + } + + #[tokio::test] + async fn test_execute_reads_stdin() { + let cwd = std::env::current_dir().unwrap(); + let actual = UserHookExecutor::execute( + "cat", + r#"{"hook_event_name": "PreToolUse"}"#, + None, + &cwd, + &HashMap::new(), + ) + .await + .unwrap(); + + assert_eq!(actual.exit_code, Some(0)); + assert!(actual.stdout.contains("PreToolUse")); + } + + #[tokio::test] + async fn test_execute_exit_code_2() { + let cwd = std::env::current_dir().unwrap(); + let actual = UserHookExecutor::execute( + "echo 'blocked' >&2; exit 2", + "{}", + None, + &cwd, + &HashMap::new(), + ) + .await + .unwrap(); + + assert_eq!(actual.exit_code, Some(2)); + assert!(actual.is_blocking_exit()); + assert!(actual.stderr.contains("blocked")); + } + + #[tokio::test] + async fn test_execute_non_blocking_error() { + let cwd = std::env::current_dir().unwrap(); + let actual = UserHookExecutor::execute( + "exit 1", + "{}", + None, + &cwd, + &HashMap::new(), + ) + .await + .unwrap(); + + assert_eq!(actual.exit_code, Some(1)); + assert!(actual.is_non_blocking_error()); + } + + #[tokio::test] + async fn test_execute_timeout() { + let cwd = std::env::current_dir().unwrap(); + let actual = UserHookExecutor::execute( + "sleep 10", + "{}", + Some(100), // 100ms timeout + &cwd, + &HashMap::new(), + ) + .await + .unwrap(); + + // Should have no exit code (killed by timeout) + assert!(actual.exit_code.is_none() || actual.is_non_blocking_error()); + assert!(actual.stderr.contains("timed out")); + } + + #[tokio::test] + async fn test_execute_with_env_vars() { + let cwd = std::env::current_dir().unwrap(); + let mut env_vars = HashMap::new(); + env_vars.insert( + "FORGE_TEST_VAR".to_string(), + "test_value".to_string(), + ); + + let actual = UserHookExecutor::execute( + "echo $FORGE_TEST_VAR", + "{}", + None, + &cwd, + &env_vars, + ) + .await + .unwrap(); + + assert_eq!(actual.exit_code, Some(0)); + assert_eq!(actual.stdout.trim(), "test_value"); + } + + #[tokio::test] + async fn test_execute_json_output() { + let cwd = std::env::current_dir().unwrap(); + let actual = UserHookExecutor::execute( + r#"echo '{"decision":"block","reason":"test"}'"#, + "{}", + None, + &cwd, + &HashMap::new(), + ) + .await + .unwrap(); + + assert!(actual.is_success()); + let output = actual.parse_output().unwrap(); + assert!(output.is_blocking()); + assert_eq!(output.reason, Some("test".to_string())); + } +} diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs new file mode 100644 index 0000000000..3925b7b57f --- /dev/null +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -0,0 +1,531 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; + +use async_trait::async_trait; +use forge_domain::{ + Conversation, EndPayload, EventData, EventHandle, HookEventInput, HookExecutionResult, + HookInput, HookOutput, RequestPayload, ResponsePayload, StartPayload, ToolcallEndPayload, + ToolcallStartPayload, UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, +}; +use regex::Regex; +use tracing::{debug, warn}; + +use super::user_hook_executor::UserHookExecutor; + +/// EventHandle implementation that bridges user-configured hooks with the +/// existing lifecycle event system. +/// +/// This handler is constructed from a `UserHookConfig` and executes matching +/// hook commands at each lifecycle event point. It wires into the existing +/// `Hook` system via `Hook::zip()`. +#[derive(Clone)] +pub struct UserHookHandler { + config: UserHookConfig, + cwd: PathBuf, + env_vars: HashMap, + /// Tracks whether a Stop hook has already fired to prevent infinite loops. + stop_hook_active: std::sync::Arc, +} + +impl UserHookHandler { + /// Creates a new user hook handler from configuration. + /// + /// # Arguments + /// * `config` - The merged user hook configuration. + /// * `cwd` - Current working directory for command execution. + /// * `project_dir` - Project root directory for `FORGE_PROJECT_DIR` env + /// var. + /// * `session_id` - Current session/conversation ID. + pub fn new( + config: UserHookConfig, + cwd: PathBuf, + project_dir: PathBuf, + session_id: String, + ) -> Self { + let mut env_vars = HashMap::new(); + env_vars.insert( + "FORGE_PROJECT_DIR".to_string(), + project_dir.to_string_lossy().to_string(), + ); + env_vars.insert("FORGE_SESSION_ID".to_string(), session_id); + env_vars.insert( + "FORGE_CWD".to_string(), + cwd.to_string_lossy().to_string(), + ); + + Self { + config, + cwd, + env_vars, + stop_hook_active: std::sync::Arc::new(AtomicBool::new(false)), + } + } + + /// Checks if the config has any hooks for the given event. + fn has_hooks(&self, event: &UserHookEventName) -> bool { + !self.config.get_groups(event).is_empty() + } + + /// Finds matching hook entries for an event, filtered by the optional + /// matcher regex against the given subject string. + fn find_matching_hooks<'a>( + groups: &'a [UserHookMatcherGroup], + subject: Option<&str>, + ) -> Vec<&'a UserHookEntry> { + let mut matching = Vec::new(); + + for group in groups { + let matches = match (&group.matcher, subject) { + (Some(pattern), Some(subj)) => { + match Regex::new(pattern) { + Ok(re) => re.is_match(subj), + Err(e) => { + warn!( + pattern = pattern, + error = %e, + "Invalid regex in hook matcher, skipping" + ); + false + } + } + } + (Some(_), None) => { + // Matcher specified but no subject to match against; skip + false + } + (None, _) => { + // No matcher means unconditional match + true + } + }; + + if matches { + matching.extend(group.hooks.iter()); + } + } + + matching + } + + /// Executes a list of hook entries and returns their results. + async fn execute_hooks( + &self, + hooks: &[&UserHookEntry], + input: &HookInput, + ) -> Vec { + let input_json = match serde_json::to_string(input) { + Ok(json) => json, + Err(e) => { + warn!(error = %e, "Failed to serialize hook input"); + return Vec::new(); + } + }; + + let mut results = Vec::new(); + for hook in hooks { + if let Some(command) = &hook.command { + match UserHookExecutor::execute( + command, + &input_json, + hook.timeout, + &self.cwd, + &self.env_vars, + ) + .await + { + Ok(result) => results.push(result), + Err(e) => { + warn!( + command = command, + error = %e, + "Hook command failed to execute" + ); + } + } + } + } + + results + } + + /// Processes hook results, returning a blocking reason if any hook blocked. + fn process_results(results: &[HookExecutionResult]) -> Option { + for result in results { + // Exit code 2 = blocking error + if result.is_blocking_exit() { + let message = result + .blocking_message() + .unwrap_or("Hook blocked execution") + .to_string(); + return Some(message); + } + + // Exit code 0 = check stdout for JSON decisions + if let Some(output) = result.parse_output() { + if output.is_blocking() { + let reason = output + .reason + .unwrap_or_else(|| "Hook blocked execution".to_string()); + return Some(reason); + } + } + + // Non-blocking errors (exit code 1, etc.) are logged but don't block + if result.is_non_blocking_error() { + warn!( + exit_code = ?result.exit_code, + stderr = result.stderr.as_str(), + "Hook command returned non-blocking error" + ); + } + } + + None + } + + /// Processes PreToolUse results, extracting updated input if present. + fn process_pre_tool_use_output(results: &[HookExecutionResult]) -> PreToolUseDecision { + for result in results { + // Exit code 2 = blocking error + if result.is_blocking_exit() { + let message = result + .blocking_message() + .unwrap_or("Hook blocked tool execution") + .to_string(); + return PreToolUseDecision::Block(message); + } + + // Exit code 0 = check stdout for JSON decisions + if let Some(output) = result.parse_output() { + // Check permission decision + if output.permission_decision.as_deref() == Some("deny") { + let reason = output + .reason + .unwrap_or_else(|| "Tool execution denied by hook".to_string()); + return PreToolUseDecision::Block(reason); + } + + // Check generic block decision + if output.is_blocking() { + let reason = output + .reason + .unwrap_or_else(|| "Hook blocked tool execution".to_string()); + return PreToolUseDecision::Block(reason); + } + + // Check for updated input + if output.updated_input.is_some() { + return PreToolUseDecision::AllowWithUpdate(output); + } + } + + // Non-blocking errors are logged but don't block + if result.is_non_blocking_error() { + warn!( + exit_code = ?result.exit_code, + stderr = result.stderr.as_str(), + "PreToolUse hook command returned non-blocking error" + ); + } + } + + PreToolUseDecision::Allow + } +} + +/// Decision result from PreToolUse hook processing. +enum PreToolUseDecision { + /// Allow the tool call to proceed. + Allow, + /// Allow but with updated input from the hook output. + AllowWithUpdate(HookOutput), + /// Block the tool call with the given reason. + Block(String), +} + +// --- EventHandle implementations --- + +#[async_trait] +impl EventHandle> for UserHookHandler { + async fn handle( + &self, + _event: &EventData, + _conversation: &mut Conversation, + ) -> anyhow::Result<()> { + if !self.has_hooks(&UserHookEventName::SessionStart) { + return Ok(()); + } + + let groups = self.config.get_groups(&UserHookEventName::SessionStart); + let hooks = Self::find_matching_hooks(groups, Some("startup")); + + if hooks.is_empty() { + return Ok(()); + } + + let input = HookInput { + hook_event_name: "SessionStart".to_string(), + cwd: self.cwd.to_string_lossy().to_string(), + session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), + event_data: HookEventInput::SessionStart { source: "startup".to_string() }, + }; + + let results = self.execute_hooks(&hooks, &input).await; + + // SessionStart hooks can provide additional context but not block + for result in &results { + if let Some(output) = result.parse_output() { + if let Some(context) = &output.additional_context { + debug!( + context_len = context.len(), + "SessionStart hook provided additional context" + ); + } + } + } + + Ok(()) + } +} + +#[async_trait] +impl EventHandle> for UserHookHandler { + async fn handle( + &self, + _event: &EventData, + _conversation: &mut Conversation, + ) -> anyhow::Result<()> { + // No user hook events map to Request currently + Ok(()) + } +} + +#[async_trait] +impl EventHandle> for UserHookHandler { + async fn handle( + &self, + _event: &EventData, + _conversation: &mut Conversation, + ) -> anyhow::Result<()> { + // No user hook events map to Response currently + Ok(()) + } +} + +#[async_trait] +impl EventHandle> for UserHookHandler { + async fn handle( + &self, + event: &EventData, + conversation: &mut Conversation, + ) -> anyhow::Result<()> { + if !self.has_hooks(&UserHookEventName::PreToolUse) { + return Ok(()); + } + + let tool_name = event.payload.tool_call.name.as_str(); + let groups = self.config.get_groups(&UserHookEventName::PreToolUse); + let hooks = Self::find_matching_hooks(groups, Some(tool_name)); + + if hooks.is_empty() { + return Ok(()); + } + + let tool_input = + serde_json::to_value(&event.payload.tool_call.arguments).unwrap_or_default(); + + let input = HookInput { + hook_event_name: "PreToolUse".to_string(), + cwd: self.cwd.to_string_lossy().to_string(), + session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), + event_data: HookEventInput::PreToolUse { + tool_name: tool_name.to_string(), + tool_input, + }, + }; + + let results = self.execute_hooks(&hooks, &input).await; + let decision = Self::process_pre_tool_use_output(&results); + + match decision { + PreToolUseDecision::Allow => Ok(()), + PreToolUseDecision::AllowWithUpdate(_output) => { + // Note: Updating tool call input would require modifying the tool call + // in-flight, which would need changes to the orchestrator. + // For now, we log and proceed. + debug!( + tool_name = tool_name, + "PreToolUse hook returned updatedInput (not yet supported for modification)" + ); + Ok(()) + } + PreToolUseDecision::Block(reason) => { + debug!( + tool_name = tool_name, + reason = reason.as_str(), + "PreToolUse hook blocked tool call" + ); + // Inject a user message with the block reason so the agent sees it + if let Some(context) = conversation.context.as_mut() { + let block_msg = format!( + "\nPreToolUse\n{}\nblocked\n{}\n", + tool_name, reason + ); + context.messages.push( + forge_domain::ContextMessage::user(block_msg, None).into(), + ); + } + // Return an error to signal the orchestrator to skip this tool call + Err(anyhow::anyhow!( + "Tool call '{}' blocked by PreToolUse hook: {}", + tool_name, + reason + )) + } + } + } +} + +#[async_trait] +impl EventHandle> for UserHookHandler { + async fn handle( + &self, + event: &EventData, + conversation: &mut Conversation, + ) -> anyhow::Result<()> { + let is_error = event.payload.result.is_error(); + let event_name = if is_error { + UserHookEventName::PostToolUseFailure + } else { + UserHookEventName::PostToolUse + }; + + if !self.has_hooks(&event_name) { + return Ok(()); + } + + let tool_name = event.payload.tool_call.name.as_str(); + let groups = self.config.get_groups(&event_name); + let hooks = Self::find_matching_hooks(groups, Some(tool_name)); + + if hooks.is_empty() { + return Ok(()); + } + + let tool_input = + serde_json::to_value(&event.payload.tool_call.arguments).unwrap_or_default(); + let tool_response = serde_json::to_value(&event.payload.result.output).unwrap_or_default(); + + let input = HookInput { + hook_event_name: event_name.to_string(), + cwd: self.cwd.to_string_lossy().to_string(), + session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), + event_data: HookEventInput::PostToolUse { + tool_name: tool_name.to_string(), + tool_input, + tool_response, + }, + }; + + let results = self.execute_hooks(&hooks, &input).await; + + // PostToolUse can provide feedback via blocking + if let Some(reason) = Self::process_results(&results) { + debug!( + tool_name = tool_name, + event = %event_name, + reason = reason.as_str(), + "PostToolUse hook blocked with feedback" + ); + // Inject feedback as a user message + if let Some(context) = conversation.context.as_mut() { + let feedback_msg = format!( + "\n{}\n{}\nblocked\n{}\n", + event_name, tool_name, reason + ); + context.messages.push( + forge_domain::ContextMessage::user(feedback_msg, None).into(), + ); + } + } + + Ok(()) + } +} + +#[async_trait] +impl EventHandle> for UserHookHandler { + async fn handle( + &self, + _event: &EventData, + conversation: &mut Conversation, + ) -> anyhow::Result<()> { + // Fire SessionEnd hooks + if self.has_hooks(&UserHookEventName::SessionEnd) { + let groups = self.config.get_groups(&UserHookEventName::SessionEnd); + let hooks = Self::find_matching_hooks(groups, None); + + if !hooks.is_empty() { + let input = HookInput { + hook_event_name: "SessionEnd".to_string(), + cwd: self.cwd.to_string_lossy().to_string(), + session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), + event_data: HookEventInput::Empty {}, + }; + self.execute_hooks(&hooks, &input).await; + } + } + + // Fire Stop hooks + if !self.has_hooks(&UserHookEventName::Stop) { + return Ok(()); + } + + // Prevent infinite loops + let was_active = self.stop_hook_active.swap(true, Ordering::SeqCst); + if was_active { + debug!("Stop hook already active, skipping to prevent infinite loop"); + return Ok(()); + } + + let groups = self.config.get_groups(&UserHookEventName::Stop); + let hooks = Self::find_matching_hooks(groups, None); + + if hooks.is_empty() { + self.stop_hook_active.store(false, Ordering::SeqCst); + return Ok(()); + } + + let input = HookInput { + hook_event_name: "Stop".to_string(), + cwd: self.cwd.to_string_lossy().to_string(), + session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), + event_data: HookEventInput::Stop { stop_hook_active: was_active }, + }; + + let results = self.execute_hooks(&hooks, &input).await; + + if let Some(reason) = Self::process_results(&results) { + debug!( + reason = reason.as_str(), + "Stop hook wants to continue conversation" + ); + // Inject a message to continue the conversation + if let Some(context) = conversation.context.as_mut() { + let continue_msg = format!( + "\nStop\ncontinue\n{}\n", + reason + ); + context + .messages + .push(forge_domain::ContextMessage::user(continue_msg, None).into()); + } + } + + // Reset the stop hook active flag + self.stop_hook_active.store(false, Ordering::SeqCst); + + Ok(()) + } +} diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs new file mode 100644 index 0000000000..ae3dbb08b8 --- /dev/null +++ b/crates/forge_domain/src/user_hook_config.rs @@ -0,0 +1,311 @@ +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Top-level user hook configuration. +/// +/// Maps hook event names to a list of matcher groups. This is deserialized +/// from the `"hooks"` key in `.forge/settings.json` or `~/.forge/settings.json`. +/// +/// Example JSON: +/// ```json +/// { +/// "PreToolUse": [ +/// { "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo hi" }] } +/// ] +/// } +/// ``` +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UserHookConfig { + /// Map of event name -> list of matcher groups + #[serde(flatten)] + pub events: HashMap>, +} + +impl UserHookConfig { + /// Creates an empty user hook configuration. + pub fn new() -> Self { + Self { events: HashMap::new() } + } + + /// Returns the matcher groups for a given event name, or an empty slice if + /// none. + pub fn get_groups(&self, event: &UserHookEventName) -> &[UserHookMatcherGroup] { + self.events.get(event).map_or(&[], |v| v.as_slice()) + } + + /// Merges another config into this one, appending matcher groups for each + /// event. + pub fn merge(&mut self, other: UserHookConfig) { + for (event, groups) in other.events { + self.events.entry(event).or_default().extend(groups); + } + } + + /// Returns true if no hook events are configured. + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Supported hook event names that map to lifecycle points in the +/// orchestrator. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum UserHookEventName { + /// Fired before a tool call executes. Can block execution. + PreToolUse, + /// Fired after a tool call succeeds. + PostToolUse, + /// Fired after a tool call fails. + PostToolUseFailure, + /// Fired when the agent finishes responding. Can block stop to continue. + Stop, + /// Fired when a notification is sent. + Notification, + /// Fired when a session starts or resumes. + SessionStart, + /// Fired when a session ends/terminates. + SessionEnd, + /// Fired when a user prompt is submitted. + UserPromptSubmit, + /// Fired before context compaction. + PreCompact, + /// Fired after context compaction. + PostCompact, +} + +impl fmt::Display for UserHookEventName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PreToolUse => write!(f, "PreToolUse"), + Self::PostToolUse => write!(f, "PostToolUse"), + Self::PostToolUseFailure => write!(f, "PostToolUseFailure"), + Self::Stop => write!(f, "Stop"), + Self::Notification => write!(f, "Notification"), + Self::SessionStart => write!(f, "SessionStart"), + Self::SessionEnd => write!(f, "SessionEnd"), + Self::UserPromptSubmit => write!(f, "UserPromptSubmit"), + Self::PreCompact => write!(f, "PreCompact"), + Self::PostCompact => write!(f, "PostCompact"), + } + } +} + +/// A matcher group pairs an optional regex matcher with a list of hook +/// handlers. +/// +/// When a lifecycle event fires, only matcher groups whose `matcher` regex +/// matches the relevant event context (e.g., tool name) will have their hooks +/// executed. If `matcher` is `None`, all hooks in this group fire +/// unconditionally. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UserHookMatcherGroup { + /// Optional regex pattern to match against (e.g., tool name for + /// PreToolUse/PostToolUse). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + + /// List of hook handlers to execute when this matcher matches. + #[serde(default)] + pub hooks: Vec, +} + +/// A single hook handler entry that defines what action to take. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UserHookEntry { + /// The type of hook handler. + #[serde(rename = "type")] + pub hook_type: UserHookType, + + /// The shell command to execute (for `Command` type hooks). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + + /// Timeout in milliseconds for this hook. Defaults to 600000ms (10 + /// minutes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// The type of hook handler to execute. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum UserHookType { + /// Executes a shell command, piping JSON to stdin and reading JSON from + /// stdout. + Command, +} + +/// Wrapper for the top-level settings JSON that contains the hooks key. +/// +/// Used for deserializing the entire settings file and extracting just the +/// `"hooks"` section. +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UserSettings { + /// User hook configuration. + #[serde(default)] + pub hooks: UserHookConfig, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_deserialize_empty_config() { + let json = r#"{}"#; + let actual: UserHookConfig = serde_json::from_str(json).unwrap(); + let expected = UserHookConfig::new(); + assert_eq!(actual, expected); + } + + #[test] + fn test_deserialize_pre_tool_use_hook() { + let json = r#"{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo 'blocked'" + } + ] + } + ] + }"#; + + let actual: UserHookConfig = serde_json::from_str(json).unwrap(); + let groups = actual.get_groups(&UserHookEventName::PreToolUse); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].matcher, Some("Bash".to_string())); + assert_eq!(groups[0].hooks.len(), 1); + assert_eq!(groups[0].hooks[0].hook_type, UserHookType::Command); + assert_eq!( + groups[0].hooks[0].command, + Some("echo 'blocked'".to_string()) + ); + } + + #[test] + fn test_deserialize_multiple_events() { + let json = r#"{ + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "pre.sh" }] } + ], + "PostToolUse": [ + { "hooks": [{ "type": "command", "command": "post.sh" }] } + ], + "Stop": [ + { "hooks": [{ "type": "command", "command": "stop.sh" }] } + ] + }"#; + + let actual: UserHookConfig = serde_json::from_str(json).unwrap(); + + assert_eq!( + actual.get_groups(&UserHookEventName::PreToolUse).len(), + 1 + ); + assert_eq!( + actual.get_groups(&UserHookEventName::PostToolUse).len(), + 1 + ); + assert_eq!(actual.get_groups(&UserHookEventName::Stop).len(), 1); + assert!(actual.get_groups(&UserHookEventName::SessionStart).is_empty()); + } + + #[test] + fn test_deserialize_hook_with_timeout() { + let json = r#"{ + "PreToolUse": [ + { + "hooks": [ + { "type": "command", "command": "slow.sh", "timeout": 30000 } + ] + } + ] + }"#; + + let actual: UserHookConfig = serde_json::from_str(json).unwrap(); + let groups = actual.get_groups(&UserHookEventName::PreToolUse); + + assert_eq!(groups[0].hooks[0].timeout, Some(30000)); + } + + #[test] + fn test_merge_configs() { + let json1 = r#"{ + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "hook1.sh" }] } + ] + }"#; + let json2 = r#"{ + "PreToolUse": [ + { "matcher": "Write", "hooks": [{ "type": "command", "command": "hook2.sh" }] } + ], + "Stop": [ + { "hooks": [{ "type": "command", "command": "stop.sh" }] } + ] + }"#; + + let mut actual: UserHookConfig = serde_json::from_str(json1).unwrap(); + let config2: UserHookConfig = serde_json::from_str(json2).unwrap(); + actual.merge(config2); + + assert_eq!( + actual.get_groups(&UserHookEventName::PreToolUse).len(), + 2 + ); + assert_eq!(actual.get_groups(&UserHookEventName::Stop).len(), 1); + } + + #[test] + fn test_deserialize_settings_with_hooks() { + let json = r#"{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } + ] + } + }"#; + + let actual: UserSettings = serde_json::from_str(json).unwrap(); + + assert!(!actual.hooks.is_empty()); + assert_eq!( + actual + .hooks + .get_groups(&UserHookEventName::PreToolUse) + .len(), + 1 + ); + } + + #[test] + fn test_deserialize_settings_without_hooks() { + let json = r#"{}"#; + let actual: UserSettings = serde_json::from_str(json).unwrap(); + + assert!(actual.hooks.is_empty()); + } + + #[test] + fn test_no_matcher_group_fires_unconditionally() { + let json = r#"{ + "PostToolUse": [ + { "hooks": [{ "type": "command", "command": "always.sh" }] } + ] + }"#; + + let actual: UserHookConfig = serde_json::from_str(json).unwrap(); + let groups = actual.get_groups(&UserHookEventName::PostToolUse); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].matcher, None); + } +} diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs new file mode 100644 index 0000000000..38fe4c4df3 --- /dev/null +++ b/crates/forge_domain/src/user_hook_io.rs @@ -0,0 +1,313 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Exit code constants for hook script results. +pub mod exit_codes { + /// Hook executed successfully. stdout may contain JSON output. + pub const SUCCESS: i32 = 0; + /// Blocking error. stderr is used as feedback message. + pub const BLOCK: i32 = 2; +} + +/// JSON input sent to hook scripts via stdin. +/// +/// Contains common fields shared across all hook events plus event-specific +/// data in the `event_data` field. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HookInput { + /// The hook event name (e.g., "PreToolUse", "PostToolUse", "Stop"). + pub hook_event_name: String, + + /// Current working directory. + pub cwd: String, + + /// Session/conversation ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + + /// Event-specific payload data. + #[serde(flatten)] + pub event_data: HookEventInput, +} + +/// Event-specific input data variants. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum HookEventInput { + /// Input for PreToolUse events. + PreToolUse { + /// Name of the tool being called. + tool_name: String, + /// Tool call arguments as a JSON value. + tool_input: Value, + }, + /// Input for PostToolUse events. + PostToolUse { + /// Name of the tool that was called. + tool_name: String, + /// Tool call arguments as a JSON value. + tool_input: Value, + /// Tool output/response as a JSON value. + tool_response: Value, + }, + /// Input for Stop events. + Stop { + /// Whether a Stop hook has already fired (prevents infinite loops). + stop_hook_active: bool, + }, + /// Input for SessionStart events. + SessionStart { + /// Source of the session start (e.g., "startup", "resume"). + source: String, + }, + /// Empty input for events that don't need event-specific data. + Empty {}, +} + +/// JSON output parsed from hook script stdout. +/// +/// Fields are optional; scripts that don't need to control behavior can simply +/// exit 0 with empty stdout. +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HookOutput { + /// Whether execution should continue. `false` halts processing. + #[serde(default, rename = "continue", skip_serializing_if = "Option::is_none")] + pub continue_execution: Option, + + /// Decision for blocking events. `"block"` blocks the operation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision: Option, + + /// Reason for blocking, used as feedback to the agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + + /// For PreToolUse: permission decision ("allow", "deny", "ask"). + #[serde( + default, + rename = "permissionDecision", + skip_serializing_if = "Option::is_none" + )] + pub permission_decision: Option, + + /// For PreToolUse: modified tool input to replace the original. + #[serde( + default, + rename = "updatedInput", + skip_serializing_if = "Option::is_none" + )] + pub updated_input: Option, + + /// Additional context to inject into the conversation. + #[serde( + default, + rename = "additionalContext", + skip_serializing_if = "Option::is_none" + )] + pub additional_context: Option, + + /// Reason for stopping (for Stop hooks). + #[serde( + default, + rename = "stopReason", + skip_serializing_if = "Option::is_none" + )] + pub stop_reason: Option, +} + +impl HookOutput { + /// Attempts to parse stdout as JSON. Falls back to empty output on failure. + pub fn parse(stdout: &str) -> Self { + if stdout.trim().is_empty() { + return Self::default(); + } + serde_json::from_str(stdout).unwrap_or_default() + } + + /// Returns true if this output requests blocking. + pub fn is_blocking(&self) -> bool { + self.decision.as_deref() == Some("block") + || self.permission_decision.as_deref() == Some("deny") + } +} + +/// Result of executing a hook command. +#[derive(Debug, Clone)] +pub struct HookExecutionResult { + /// Process exit code (None if terminated by signal). + pub exit_code: Option, + /// Captured stdout. + pub stdout: String, + /// Captured stderr. + pub stderr: String, +} + +impl HookExecutionResult { + /// Returns true if the hook exited with the blocking exit code (2). + pub fn is_blocking_exit(&self) -> bool { + self.exit_code == Some(exit_codes::BLOCK) + } + + /// Returns true if the hook exited successfully (0). + pub fn is_success(&self) -> bool { + self.exit_code == Some(exit_codes::SUCCESS) + } + + /// Returns true if the hook exited with a non-blocking error (non-0, + /// non-2). + pub fn is_non_blocking_error(&self) -> bool { + match self.exit_code { + Some(code) => code != exit_codes::SUCCESS && code != exit_codes::BLOCK, + None => true, + } + } + + /// Parses the stdout as a HookOutput if the exit was successful. + pub fn parse_output(&self) -> Option { + if self.is_success() { + Some(HookOutput::parse(&self.stdout)) + } else { + None + } + } + + /// Returns the feedback message for blocking errors (stderr content). + pub fn blocking_message(&self) -> Option<&str> { + if self.is_blocking_exit() { + let msg = self.stderr.trim(); + if msg.is_empty() { None } else { Some(msg) } + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_hook_input_serialization_pre_tool_use() { + let fixture = HookInput { + hook_event_name: "PreToolUse".to_string(), + cwd: "/project".to_string(), + session_id: Some("sess-123".to_string()), + event_data: HookEventInput::PreToolUse { + tool_name: "Bash".to_string(), + tool_input: serde_json::json!({"command": "ls"}), + }, + }; + + let actual = serde_json::to_value(&fixture).unwrap(); + + assert_eq!(actual["hook_event_name"], "PreToolUse"); + assert_eq!(actual["cwd"], "/project"); + assert_eq!(actual["tool_name"], "Bash"); + assert_eq!(actual["tool_input"]["command"], "ls"); + } + + #[test] + fn test_hook_input_serialization_stop() { + let fixture = HookInput { + hook_event_name: "Stop".to_string(), + cwd: "/project".to_string(), + session_id: None, + event_data: HookEventInput::Stop { stop_hook_active: false }, + }; + + let actual = serde_json::to_value(&fixture).unwrap(); + + assert_eq!(actual["hook_event_name"], "Stop"); + assert_eq!(actual["stop_hook_active"], false); + } + + #[test] + fn test_hook_output_parse_valid_json() { + let stdout = r#"{"decision": "block", "reason": "unsafe command"}"#; + let actual = HookOutput::parse(stdout); + + assert_eq!(actual.decision, Some("block".to_string())); + assert_eq!(actual.reason, Some("unsafe command".to_string())); + } + + #[test] + fn test_hook_output_parse_empty_string() { + let actual = HookOutput::parse(""); + let expected = HookOutput::default(); + assert_eq!(actual, expected); + } + + #[test] + fn test_hook_output_parse_invalid_json_returns_default() { + let actual = HookOutput::parse("not json at all"); + let expected = HookOutput::default(); + assert_eq!(actual, expected); + } + + #[test] + fn test_hook_output_is_blocking() { + let fixture = HookOutput { + decision: Some("block".to_string()), + ..Default::default() + }; + assert!(fixture.is_blocking()); + + let fixture = HookOutput { + permission_decision: Some("deny".to_string()), + ..Default::default() + }; + assert!(fixture.is_blocking()); + + let fixture = HookOutput::default(); + assert!(!fixture.is_blocking()); + } + + #[test] + fn test_hook_execution_result_blocking() { + let fixture = HookExecutionResult { + exit_code: Some(2), + stdout: String::new(), + stderr: "Blocked: unsafe command".to_string(), + }; + + assert!(fixture.is_blocking_exit()); + assert!(!fixture.is_success()); + assert!(!fixture.is_non_blocking_error()); + assert_eq!( + fixture.blocking_message(), + Some("Blocked: unsafe command") + ); + assert!(fixture.parse_output().is_none()); + } + + #[test] + fn test_hook_execution_result_success() { + let fixture = HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "test"}"#.to_string(), + stderr: String::new(), + }; + + assert!(fixture.is_success()); + assert!(!fixture.is_blocking_exit()); + assert!(!fixture.is_non_blocking_error()); + let output = fixture.parse_output().unwrap(); + assert!(output.is_blocking()); + } + + #[test] + fn test_hook_execution_result_non_blocking_error() { + let fixture = HookExecutionResult { + exit_code: Some(1), + stdout: String::new(), + stderr: "some error".to_string(), + }; + + assert!(fixture.is_non_blocking_error()); + assert!(!fixture.is_success()); + assert!(!fixture.is_blocking_exit()); + assert!(fixture.blocking_message().is_none()); + } +} From cd1d97eb962d842172f431d8f862842f21b5126c Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:33:36 -0400 Subject: [PATCH 02/64] feat(hooks): integrate user-configurable hooks into app lifecycle and handle PreToolUse blocking in orchestrator --- .gitignore | 2 + crates/forge_app/src/app.rs | 30 +- crates/forge_app/src/hooks/mod.rs | 5 + .../forge_app/src/hooks/user_hook_handler.rs | 258 +++++++++++++++--- crates/forge_app/src/orch.rs | 24 +- crates/forge_domain/src/lib.rs | 4 + 6 files changed, 277 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 077bfbece7..66351b131c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ Cargo.lock **/.forge/request.body.json node_modules/ bench/__pycache__ +/hooksref* +#/cc diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 69a83aa447..de56d9ef28 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -8,7 +8,10 @@ use forge_stream::MpscStream; use crate::apply_tunable_parameters::ApplyTunableParameters; use crate::changed_files::ChangedFiles; use crate::dto::ToolsOverview; -use crate::hooks::{CompactionHandler, DoomLoopDetector, TitleGenerationHandler, TracingHandler}; +use crate::hooks::{ + CompactionHandler, DoomLoopDetector, TitleGenerationHandler, TracingHandler, + UserHookConfigLoader, UserHookHandler, +}; use crate::init_conversation_metrics::InitConversationMetrics; use crate::orch::Orchestrator; use crate::services::{AgentRegistry, CustomInstructionsService, ProviderAuthService}; @@ -124,7 +127,7 @@ impl ForgeApp { // Create the orchestrator with all necessary dependencies let tracing_handler = TracingHandler::new(); let title_handler = TitleGenerationHandler::new(services.clone()); - let hook = Hook::default() + let internal_hook = Hook::default() .on_start(tracing_handler.clone().and(title_handler.clone())) .on_request(tracing_handler.clone().and(DoomLoopDetector::default())) .on_response( @@ -136,6 +139,29 @@ impl ForgeApp { .on_toolcall_end(tracing_handler.clone()) .on_end(tracing_handler.and(title_handler)); + // Load user-configurable hooks from settings files + let user_hook_config = + UserHookConfigLoader::load(environment.home.as_deref(), &environment.cwd); + + let hook = if !user_hook_config.is_empty() { + let user_handler = UserHookHandler::new( + user_hook_config, + environment.cwd.clone(), + environment.cwd.clone(), + conversation.id.to_string(), + ); + let user_hook = Hook::default() + .on_start(user_handler.clone()) + .on_request(user_handler.clone()) + .on_response(user_handler.clone()) + .on_toolcall_start(user_handler.clone()) + .on_toolcall_end(user_handler.clone()) + .on_end(user_handler); + internal_hook.zip(user_hook) + } else { + internal_hook + }; + let orch = Orchestrator::new(services.clone(), environment.clone(), conversation, agent) .error_tracker(ToolErrorTracker::new(max_tool_failure_per_turn)) .tool_definitions(tool_definitions) diff --git a/crates/forge_app/src/hooks/mod.rs b/crates/forge_app/src/hooks/mod.rs index fb5447a8e6..712fc5a2a4 100644 --- a/crates/forge_app/src/hooks/mod.rs +++ b/crates/forge_app/src/hooks/mod.rs @@ -2,8 +2,13 @@ mod compaction; mod doom_loop; mod title_generation; mod tracing; +mod user_hook_config_loader; +mod user_hook_executor; +mod user_hook_handler; pub use compaction::CompactionHandler; pub use doom_loop::DoomLoopDetector; pub use title_generation::TitleGenerationHandler; pub use tracing::TracingHandler; +pub use user_hook_config_loader::UserHookConfigLoader; +pub use user_hook_handler::UserHookHandler; diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 3925b7b57f..c77e97fe8c 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -49,10 +49,7 @@ impl UserHookHandler { project_dir.to_string_lossy().to_string(), ); env_vars.insert("FORGE_SESSION_ID".to_string(), session_id); - env_vars.insert( - "FORGE_CWD".to_string(), - cwd.to_string_lossy().to_string(), - ); + env_vars.insert("FORGE_CWD".to_string(), cwd.to_string_lossy().to_string()); Self { config, @@ -77,19 +74,17 @@ impl UserHookHandler { for group in groups { let matches = match (&group.matcher, subject) { - (Some(pattern), Some(subj)) => { - match Regex::new(pattern) { - Ok(re) => re.is_match(subj), - Err(e) => { - warn!( - pattern = pattern, - error = %e, - "Invalid regex in hook matcher, skipping" - ); - false - } + (Some(pattern), Some(subj)) => match Regex::new(pattern) { + Ok(re) => re.is_match(subj), + Err(e) => { + warn!( + pattern = pattern, + error = %e, + "Invalid regex in hook matcher, skipping" + ); + false } - } + }, (Some(_), None) => { // Matcher specified but no subject to match against; skip false @@ -132,7 +127,7 @@ impl UserHookHandler { &self.cwd, &self.env_vars, ) - .await + .await { Ok(result) => results.push(result), Err(e) => { @@ -318,7 +313,7 @@ impl EventHandle> for UserHookHandler { async fn handle( &self, event: &EventData, - conversation: &mut Conversation, + _conversation: &mut Conversation, ) -> anyhow::Result<()> { if !self.has_hooks(&UserHookEventName::PreToolUse) { return Ok(()); @@ -339,10 +334,7 @@ impl EventHandle> for UserHookHandler { hook_event_name: "PreToolUse".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::PreToolUse { - tool_name: tool_name.to_string(), - tool_input, - }, + event_data: HookEventInput::PreToolUse { tool_name: tool_name.to_string(), tool_input }, }; let results = self.execute_hooks(&hooks, &input).await; @@ -366,17 +358,9 @@ impl EventHandle> for UserHookHandler { reason = reason.as_str(), "PreToolUse hook blocked tool call" ); - // Inject a user message with the block reason so the agent sees it - if let Some(context) = conversation.context.as_mut() { - let block_msg = format!( - "\nPreToolUse\n{}\nblocked\n{}\n", - tool_name, reason - ); - context.messages.push( - forge_domain::ContextMessage::user(block_msg, None).into(), - ); - } - // Return an error to signal the orchestrator to skip this tool call + // Return an error to signal the orchestrator to skip this tool call. + // The orchestrator converts this into an error ToolResult visible to + // the model. Err(anyhow::anyhow!( "Tool call '{}' blocked by PreToolUse hook: {}", tool_name, @@ -444,9 +428,9 @@ impl EventHandle> for UserHookHandler { "\n{}\n{}\nblocked\n{}\n", event_name, tool_name, reason ); - context.messages.push( - forge_domain::ContextMessage::user(feedback_msg, None).into(), - ); + context + .messages + .push(forge_domain::ContextMessage::user(feedback_msg, None).into()); } } @@ -529,3 +513,205 @@ impl EventHandle> for UserHookHandler { Ok(()) } } + +#[cfg(test)] +mod tests { + use forge_domain::{ + HookExecutionResult, UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType, + }; + use pretty_assertions::assert_eq; + + use super::*; + + fn make_entry(command: &str) -> UserHookEntry { + UserHookEntry { + hook_type: UserHookType::Command, + command: Some(command.to_string()), + timeout: None, + } + } + + fn make_group(matcher: Option<&str>, commands: &[&str]) -> UserHookMatcherGroup { + UserHookMatcherGroup { + matcher: matcher.map(|s| s.to_string()), + hooks: commands.iter().map(|c| make_entry(c)).collect(), + } + } + + #[test] + fn test_find_matching_hooks_no_matcher_fires_unconditionally() { + let groups = vec![make_group(None, &["echo hi"])]; + let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].command, Some("echo hi".to_string())); + } + + #[test] + fn test_find_matching_hooks_no_matcher_fires_without_subject() { + let groups = vec![make_group(None, &["echo hi"])]; + let actual = UserHookHandler::find_matching_hooks(&groups, None); + assert_eq!(actual.len(), 1); + } + + #[test] + fn test_find_matching_hooks_regex_match() { + let groups = vec![make_group(Some("Bash"), &["block.sh"])]; + let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + assert_eq!(actual.len(), 1); + } + + #[test] + fn test_find_matching_hooks_regex_no_match() { + let groups = vec![make_group(Some("Bash"), &["block.sh"])]; + let actual = UserHookHandler::find_matching_hooks(&groups, Some("Write")); + assert!(actual.is_empty()); + } + + #[test] + fn test_find_matching_hooks_regex_partial_match() { + let groups = vec![make_group(Some("Bash|Write"), &["check.sh"])]; + let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + assert_eq!(actual.len(), 1); + } + + #[test] + fn test_find_matching_hooks_matcher_but_no_subject() { + let groups = vec![make_group(Some("Bash"), &["block.sh"])]; + let actual = UserHookHandler::find_matching_hooks(&groups, None); + assert!(actual.is_empty()); + } + + #[test] + fn test_find_matching_hooks_invalid_regex_skipped() { + let groups = vec![make_group(Some("[invalid"), &["block.sh"])]; + let actual = UserHookHandler::find_matching_hooks(&groups, Some("anything")); + assert!(actual.is_empty()); + } + + #[test] + fn test_find_matching_hooks_multiple_groups() { + let groups = vec![ + make_group(Some("Bash"), &["bash-hook.sh"]), + make_group(Some("Write"), &["write-hook.sh"]), + make_group(None, &["always.sh"]), + ]; + let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + assert_eq!(actual.len(), 2); // Bash match + unconditional + } + + #[test] + fn test_process_pre_tool_use_output_allow_on_success() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }]; + let actual = UserHookHandler::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Allow)); + } + + #[test] + fn test_process_pre_tool_use_output_block_on_exit_2() { + let results = vec![HookExecutionResult { + exit_code: Some(2), + stdout: String::new(), + stderr: "Blocked: dangerous command".to_string(), + }]; + let actual = UserHookHandler::process_pre_tool_use_output(&results); + assert!( + matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("dangerous command")) + ); + } + + #[test] + fn test_process_pre_tool_use_output_block_on_deny() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"permissionDecision": "deny", "reason": "Not allowed"}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Not allowed")); + } + + #[test] + fn test_process_pre_tool_use_output_block_on_decision() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "Blocked by policy"}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Blocked by policy")); + } + + #[test] + fn test_process_pre_tool_use_output_non_blocking_error_allows() { + let results = vec![HookExecutionResult { + exit_code: Some(1), + stdout: String::new(), + stderr: "some error".to_string(), + }]; + let actual = UserHookHandler::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Allow)); + } + + #[test] + fn test_process_results_no_blocking() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }]; + let actual = UserHookHandler::process_results(&results); + assert!(actual.is_none()); + } + + #[test] + fn test_process_results_blocking_exit_code() { + let results = vec![HookExecutionResult { + exit_code: Some(2), + stdout: String::new(), + stderr: "stop reason".to_string(), + }]; + let actual = UserHookHandler::process_results(&results); + assert_eq!(actual, Some("stop reason".to_string())); + } + + #[test] + fn test_process_results_blocking_json_decision() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "keep going"}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::process_results(&results); + assert_eq!(actual, Some("keep going".to_string())); + } + + #[test] + fn test_has_hooks_returns_false_for_empty_config() { + let config = UserHookConfig::new(); + let handler = UserHookHandler::new( + config, + PathBuf::from("/tmp"), + PathBuf::from("/tmp"), + "sess-1".to_string(), + ); + assert!(!handler.has_hooks(&UserHookEventName::PreToolUse)); + } + + #[test] + fn test_has_hooks_returns_true_when_configured() { + let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + let handler = UserHookHandler::new( + config, + PathBuf::from("/tmp"), + PathBuf::from("/tmp"), + "sess-1".to_string(), + ); + assert!(handler.has_hooks(&UserHookEventName::PreToolUse)); + assert!(!handler.has_hooks(&UserHookEventName::Stop)); + } +} diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index 853118dd66..a3a16642f8 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -83,22 +83,30 @@ impl Orchestrator { notifier.notified().await; } - // Fire the ToolcallStart lifecycle event + // Fire the ToolcallStart lifecycle event. + // If a hook returns an error (e.g., PreToolUse hook blocked the + // call), skip execution and record an error result instead. let toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( self.agent.clone(), self.agent.model.clone(), ToolcallStartPayload::new(tool_call.clone()), )); - self.hook + let hook_result = self + .hook .handle(&toolcall_start_event, &mut self.conversation) - .await?; - - // Execute the tool - let tool_result = self - .services - .call(&self.agent, tool_context, tool_call.clone()) .await; + let tool_result = if let Err(hook_err) = hook_result { + // Hook blocked this tool call — produce an error ToolResult + // so the model sees feedback without aborting the session. + ToolResult::from(tool_call.clone()).failure(hook_err) + } else { + // Execute the tool normally + self.services + .call(&self.agent, tool_context, tool_call.clone()) + .await + }; + // Fire the ToolcallEnd lifecycle event (fires on both success and failure) let toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( self.agent.clone(), diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index e8b4e74a99..2433fe9ba8 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -53,6 +53,8 @@ mod top_k; mod top_p; mod transformer; mod update; +mod user_hook_config; +mod user_hook_io; mod validation; mod workspace; mod xml; @@ -110,6 +112,8 @@ pub use top_k::*; pub use top_p::*; pub use transformer::*; pub use update::*; +pub use user_hook_config::*; +pub use user_hook_io::*; pub use validation::*; pub use workspace::*; pub use xml::*; From 8b8747c146b727c72eef88ce2cc6c4d413f93b0f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:35:47 +0000 Subject: [PATCH 03/64] [autofix.ci] apply automated fixes --- .../src/hooks/user_hook_config_loader.rs | 3 +- .../forge_app/src/hooks/user_hook_executor.rs | 36 ++++++------------- .../forge_app/src/hooks/user_hook_handler.rs | 12 +++---- crates/forge_domain/src/user_hook_config.rs | 24 ++++++------- crates/forge_domain/src/user_hook_io.rs | 10 ++---- 5 files changed, 28 insertions(+), 57 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_config_loader.rs b/crates/forge_app/src/hooks/user_hook_config_loader.rs index 7c24b70e05..519b8eb4ef 100644 --- a/crates/forge_app/src/hooks/user_hook_config_loader.rs +++ b/crates/forge_app/src/hooks/user_hook_config_loader.rs @@ -187,8 +187,7 @@ mod tests { ) .unwrap(); - let actual = - UserHookConfigLoader::load(Some(home_dir.path()), project_dir.path()); + let actual = UserHookConfigLoader::load(Some(home_dir.path()), project_dir.path()); // PreToolUse should have 2 groups (global + project) assert_eq!( diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index 2faf677d3c..4c453f4471 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -134,10 +134,9 @@ mod tests { #[tokio::test] async fn test_execute_simple_command() { let cwd = std::env::current_dir().unwrap(); - let actual = - UserHookExecutor::execute("echo hello", "{}", None, &cwd, &HashMap::new()) - .await - .unwrap(); + let actual = UserHookExecutor::execute("echo hello", "{}", None, &cwd, &HashMap::new()) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(0)); assert_eq!(actual.stdout.trim(), "hello"); @@ -182,15 +181,9 @@ mod tests { #[tokio::test] async fn test_execute_non_blocking_error() { let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute( - "exit 1", - "{}", - None, - &cwd, - &HashMap::new(), - ) - .await - .unwrap(); + let actual = UserHookExecutor::execute("exit 1", "{}", None, &cwd, &HashMap::new()) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(1)); assert!(actual.is_non_blocking_error()); @@ -218,20 +211,11 @@ mod tests { async fn test_execute_with_env_vars() { let cwd = std::env::current_dir().unwrap(); let mut env_vars = HashMap::new(); - env_vars.insert( - "FORGE_TEST_VAR".to_string(), - "test_value".to_string(), - ); + env_vars.insert("FORGE_TEST_VAR".to_string(), "test_value".to_string()); - let actual = UserHookExecutor::execute( - "echo $FORGE_TEST_VAR", - "{}", - None, - &cwd, - &env_vars, - ) - .await - .unwrap(); + let actual = UserHookExecutor::execute("echo $FORGE_TEST_VAR", "{}", None, &cwd, &env_vars) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(0)); assert_eq!(actual.stdout.trim(), "test_value"); diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index c77e97fe8c..58efa0c003 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -127,7 +127,7 @@ impl UserHookHandler { &self.cwd, &self.env_vars, ) - .await + .await { Ok(result) => results.push(result), Err(e) => { @@ -157,14 +157,13 @@ impl UserHookHandler { } // Exit code 0 = check stdout for JSON decisions - if let Some(output) = result.parse_output() { - if output.is_blocking() { + if let Some(output) = result.parse_output() + && output.is_blocking() { let reason = output .reason .unwrap_or_else(|| "Hook blocked execution".to_string()); return Some(reason); } - } // Non-blocking errors (exit code 1, etc.) are logged but don't block if result.is_non_blocking_error() { @@ -270,14 +269,13 @@ impl EventHandle> for UserHookHandler { // SessionStart hooks can provide additional context but not block for result in &results { - if let Some(output) = result.parse_output() { - if let Some(context) = &output.additional_context { + if let Some(output) = result.parse_output() + && let Some(context) = &output.additional_context { debug!( context_len = context.len(), "SessionStart hook provided additional context" ); } - } } Ok(()) diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs index ae3dbb08b8..306e0bc088 100644 --- a/crates/forge_domain/src/user_hook_config.rs +++ b/crates/forge_domain/src/user_hook_config.rs @@ -6,7 +6,8 @@ use serde::{Deserialize, Serialize}; /// Top-level user hook configuration. /// /// Maps hook event names to a list of matcher groups. This is deserialized -/// from the `"hooks"` key in `.forge/settings.json` or `~/.forge/settings.json`. +/// from the `"hooks"` key in `.forge/settings.json` or +/// `~/.forge/settings.json`. /// /// Example JSON: /// ```json @@ -207,16 +208,14 @@ mod tests { let actual: UserHookConfig = serde_json::from_str(json).unwrap(); - assert_eq!( - actual.get_groups(&UserHookEventName::PreToolUse).len(), - 1 - ); - assert_eq!( - actual.get_groups(&UserHookEventName::PostToolUse).len(), - 1 - ); + assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 1); + assert_eq!(actual.get_groups(&UserHookEventName::PostToolUse).len(), 1); assert_eq!(actual.get_groups(&UserHookEventName::Stop).len(), 1); - assert!(actual.get_groups(&UserHookEventName::SessionStart).is_empty()); + assert!( + actual + .get_groups(&UserHookEventName::SessionStart) + .is_empty() + ); } #[test] @@ -257,10 +256,7 @@ mod tests { let config2: UserHookConfig = serde_json::from_str(json2).unwrap(); actual.merge(config2); - assert_eq!( - actual.get_groups(&UserHookEventName::PreToolUse).len(), - 2 - ); + assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 2); assert_eq!(actual.get_groups(&UserHookEventName::Stop).len(), 1); } diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 38fe4c4df3..115f62a673 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -248,10 +248,7 @@ mod tests { #[test] fn test_hook_output_is_blocking() { - let fixture = HookOutput { - decision: Some("block".to_string()), - ..Default::default() - }; + let fixture = HookOutput { decision: Some("block".to_string()), ..Default::default() }; assert!(fixture.is_blocking()); let fixture = HookOutput { @@ -275,10 +272,7 @@ mod tests { assert!(fixture.is_blocking_exit()); assert!(!fixture.is_success()); assert!(!fixture.is_non_blocking_error()); - assert_eq!( - fixture.blocking_message(), - Some("Blocked: unsafe command") - ); + assert_eq!(fixture.blocking_message(), Some("Blocked: unsafe command")); assert!(fixture.parse_output().is_none()); } From e831ba4e23eb18bcfebb651a52ba975fdbd01306 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:37:35 +0000 Subject: [PATCH 04/64] [autofix.ci] apply automated fixes (attempt 2/3) --- .../forge_app/src/hooks/user_hook_handler.rs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 58efa0c003..4f5dbd0023 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -158,12 +158,13 @@ impl UserHookHandler { // Exit code 0 = check stdout for JSON decisions if let Some(output) = result.parse_output() - && output.is_blocking() { - let reason = output - .reason - .unwrap_or_else(|| "Hook blocked execution".to_string()); - return Some(reason); - } + && output.is_blocking() + { + let reason = output + .reason + .unwrap_or_else(|| "Hook blocked execution".to_string()); + return Some(reason); + } // Non-blocking errors (exit code 1, etc.) are logged but don't block if result.is_non_blocking_error() { @@ -270,12 +271,13 @@ impl EventHandle> for UserHookHandler { // SessionStart hooks can provide additional context but not block for result in &results { if let Some(output) = result.parse_output() - && let Some(context) = &output.additional_context { - debug!( - context_len = context.len(), - "SessionStart hook provided additional context" - ); - } + && let Some(context) = &output.additional_context + { + debug!( + context_len = context.len(), + "SessionStart hook provided additional context" + ); + } } Ok(()) From 9d96e9b39c51b3a32f9717e0ef3f23e88a17bad3 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:51:03 -0400 Subject: [PATCH 05/64] feat(hooks): add user hook config service with multi-source merge logic --- crates/forge_services/src/user_hook_config.rs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 crates/forge_services/src/user_hook_config.rs diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs new file mode 100644 index 0000000000..eba90d388d --- /dev/null +++ b/crates/forge_services/src/user_hook_config.rs @@ -0,0 +1,269 @@ +use std::path::Path; +use std::sync::Arc; + +use forge_app::{EnvironmentInfra, FileReaderInfra}; +use forge_domain::{UserHookConfig, UserSettings}; +use tracing::{debug, warn}; + +/// Loads and merges user hook configurations from the three settings file +/// locations using infrastructure abstractions. +/// +/// Resolution order (all merged, not overridden): +/// 1. `~/.forge/settings.json` (user-level, applies to all projects) +/// 2. `.forge/settings.json` (project-level, committable) +/// 3. `.forge/settings.local.json` (project-level, gitignored) +pub struct ForgeUserHookConfigService(Arc); + +impl ForgeUserHookConfigService { + /// Creates a new service with the given infrastructure dependency. + pub fn new(infra: Arc) -> Self { + Self(infra) + } +} + +impl ForgeUserHookConfigService { + /// Loads a single settings file and extracts hook configuration. + /// + /// Returns `None` if the file doesn't exist or is invalid. + async fn load_file(&self, path: &Path) -> Option { + let contents = match self.0.read_utf8(path).await { + Ok(c) => c, + Err(_) => return None, + }; + + match serde_json::from_str::(&contents) { + Ok(settings) => { + if settings.hooks.is_empty() { + None + } else { + Some(settings.hooks) + } + } + Err(e) => { + warn!( + path = %path.display(), + error = %e, + "Failed to parse settings file for hooks" + ); + None + } + } + } +} + +#[async_trait::async_trait] +impl forge_app::UserHookConfigService + for ForgeUserHookConfigService +{ + async fn get_user_hook_config(&self) -> anyhow::Result { + let env = self.0.get_environment(); + let mut config = UserHookConfig::new(); + + // 1. User-level: ~/.forge/settings.json + if let Some(home) = &env.home { + let user_settings_path = home.join("forge").join("settings.json"); + if let Some(user_config) = self.load_file(&user_settings_path).await { + debug!(path = %user_settings_path.display(), "Loaded user-level hook config"); + config.merge(user_config); + } + } + + // 2. Project-level: .forge/settings.json + let project_settings_path = env.cwd.join(".forge").join("settings.json"); + if let Some(project_config) = self.load_file(&project_settings_path).await { + debug!(path = %project_settings_path.display(), "Loaded project-level hook config"); + config.merge(project_config); + } + + // 3. Project-local: .forge/settings.local.json + let local_settings_path = env.cwd.join(".forge").join("settings.local.json"); + if let Some(local_config) = self.load_file(&local_settings_path).await { + debug!(path = %local_settings_path.display(), "Loaded project-local hook config"); + config.merge(local_config); + } + + if !config.is_empty() { + debug!( + event_count = config.events.len(), + "Merged user hook configuration" + ); + } + + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use pretty_assertions::assert_eq; + + use super::*; + + #[tokio::test] + async fn test_load_file_valid_settings() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } + ] + } + }"#, + ) + .unwrap(); + + let infra = TestInfra { + home: None, + cwd: PathBuf::from("/nonexistent"), + }; + let service = ForgeUserHookConfigService::new(Arc::new(infra)); + + let actual = service.load_file(&settings_path).await; + assert!(actual.is_some()); + let config = actual.unwrap(); + assert_eq!( + config + .get_groups(&forge_domain::UserHookEventName::PreToolUse) + .len(), + 1 + ); + } + + #[tokio::test] + async fn test_load_file_settings_without_hooks() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write(&settings_path, r#"{"other_key": "value"}"#).unwrap(); + + let infra = TestInfra { + home: None, + cwd: PathBuf::from("/nonexistent"), + }; + let service = ForgeUserHookConfigService::new(Arc::new(infra)); + + let actual = service.load_file(&settings_path).await; + assert!(actual.is_none()); + } + + #[tokio::test] + async fn test_get_user_hook_config_nonexistent_paths() { + let infra = TestInfra { + home: Some(PathBuf::from("/nonexistent/home")), + cwd: PathBuf::from("/nonexistent/project"), + }; + let service = ForgeUserHookConfigService::new(Arc::new(infra)); + + let actual = service.get_user_hook_config().await.unwrap(); + assert!(actual.is_empty()); + } + + #[tokio::test] + async fn test_get_user_hook_config_merges_all_sources() { + // Set up a fake home directory + let home_dir = tempfile::tempdir().unwrap(); + let forge_dir = home_dir.path().join("forge"); + std::fs::create_dir_all(&forge_dir).unwrap(); + std::fs::write( + forge_dir.join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "global.sh" }] } + ] + } + }"#, + ) + .unwrap(); + + // Set up a project directory + let project_dir = tempfile::tempdir().unwrap(); + let project_forge_dir = project_dir.path().join(".forge"); + std::fs::create_dir_all(&project_forge_dir).unwrap(); + std::fs::write( + project_forge_dir.join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { "matcher": "Write", "hooks": [{ "type": "command", "command": "project.sh" }] } + ] + } + }"#, + ) + .unwrap(); + std::fs::write( + project_forge_dir.join("settings.local.json"), + r#"{ + "hooks": { + "Stop": [ + { "hooks": [{ "type": "command", "command": "local-stop.sh" }] } + ] + } + }"#, + ) + .unwrap(); + + let infra = TestInfra { + home: Some(home_dir.path().to_path_buf()), + cwd: project_dir.path().to_path_buf(), + }; + let service = ForgeUserHookConfigService::new(Arc::new(infra)); + + let actual = service.get_user_hook_config().await.unwrap(); + + // PreToolUse should have 2 groups (global + project) + assert_eq!( + actual + .get_groups(&forge_domain::UserHookEventName::PreToolUse) + .len(), + 2 + ); + // Stop should have 1 group (local) + assert_eq!( + actual + .get_groups(&forge_domain::UserHookEventName::Stop) + .len(), + 1 + ); + } + + // --- Test infrastructure --- + + struct TestInfra { + home: Option, + cwd: PathBuf, + } + + #[async_trait::async_trait] + impl FileReaderInfra for TestInfra { + async fn read_utf8(&self, path: &Path) -> anyhow::Result { + Ok(tokio::fs::read_to_string(path).await?) + } + + async fn read_utf8_batch( + &self, + _paths: Vec, + _batch_size: usize, + ) -> forge_stream::MpscStream<(PathBuf, anyhow::Result)> { + unimplemented!("not needed for tests") + } + } + + impl EnvironmentInfra for TestInfra { + fn get_env_var(&self, _key: &str) -> Option { + None + } + fn get_env_vars(&self) -> std::collections::BTreeMap { + Default::default() + } + fn get_environment(&self) -> forge_domain::Environment { + forge_domain::Environment::default() + .home(self.home.clone()) + .cwd(self.cwd.clone()) + } + } +} From cb3e3e2eee6594a1c20d4ddd07e205e91fab6304 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:51:08 -0400 Subject: [PATCH 06/64] feat(hooks): add configurable hook timeout via hook_timeout_ms config --- crates/forge_app/src/app.rs | 11 +- crates/forge_app/src/hooks/mod.rs | 2 - .../src/hooks/user_hook_config_loader.rs | 207 ------------------ .../forge_app/src/hooks/user_hook_executor.rs | 36 ++- .../forge_app/src/hooks/user_hook_handler.rs | 9 + crates/forge_app/src/orch_spec/orch_setup.rs | 1 + crates/forge_app/src/services.rs | 23 ++ crates/forge_config/.forge.toml | 1 + crates/forge_config/src/config.rs | 4 + crates/forge_domain/src/env.rs | 4 + crates/forge_infra/src/env.rs | 2 + crates/forge_main/src/info.rs | 1 + crates/forge_services/src/forge_services.rs | 9 + crates/forge_services/src/lib.rs | 1 + crates/forge_services/src/user_hook_config.rs | 76 ++++--- forge.schema.json | 9 + 16 files changed, 145 insertions(+), 251 deletions(-) delete mode 100644 crates/forge_app/src/hooks/user_hook_config_loader.rs diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index de56d9ef28..a9434e2f79 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -9,12 +9,13 @@ use crate::apply_tunable_parameters::ApplyTunableParameters; use crate::changed_files::ChangedFiles; use crate::dto::ToolsOverview; use crate::hooks::{ - CompactionHandler, DoomLoopDetector, TitleGenerationHandler, TracingHandler, - UserHookConfigLoader, UserHookHandler, + CompactionHandler, DoomLoopDetector, TitleGenerationHandler, TracingHandler, UserHookHandler, }; use crate::init_conversation_metrics::InitConversationMetrics; use crate::orch::Orchestrator; -use crate::services::{AgentRegistry, CustomInstructionsService, ProviderAuthService}; +use crate::services::{ + AgentRegistry, CustomInstructionsService, ProviderAuthService, UserHookConfigService, +}; use crate::set_conversation_id::SetConversationId; use crate::system_prompt::SystemPrompt; use crate::tool_registry::ToolRegistry; @@ -140,8 +141,7 @@ impl ForgeApp { .on_end(tracing_handler.and(title_handler)); // Load user-configurable hooks from settings files - let user_hook_config = - UserHookConfigLoader::load(environment.home.as_deref(), &environment.cwd); + let user_hook_config = services.get_user_hook_config().await?; let hook = if !user_hook_config.is_empty() { let user_handler = UserHookHandler::new( @@ -149,6 +149,7 @@ impl ForgeApp { environment.cwd.clone(), environment.cwd.clone(), conversation.id.to_string(), + environment.hook_timeout, ); let user_hook = Hook::default() .on_start(user_handler.clone()) diff --git a/crates/forge_app/src/hooks/mod.rs b/crates/forge_app/src/hooks/mod.rs index 712fc5a2a4..cdc5c8f0af 100644 --- a/crates/forge_app/src/hooks/mod.rs +++ b/crates/forge_app/src/hooks/mod.rs @@ -2,7 +2,6 @@ mod compaction; mod doom_loop; mod title_generation; mod tracing; -mod user_hook_config_loader; mod user_hook_executor; mod user_hook_handler; @@ -10,5 +9,4 @@ pub use compaction::CompactionHandler; pub use doom_loop::DoomLoopDetector; pub use title_generation::TitleGenerationHandler; pub use tracing::TracingHandler; -pub use user_hook_config_loader::UserHookConfigLoader; pub use user_hook_handler::UserHookHandler; diff --git a/crates/forge_app/src/hooks/user_hook_config_loader.rs b/crates/forge_app/src/hooks/user_hook_config_loader.rs deleted file mode 100644 index 519b8eb4ef..0000000000 --- a/crates/forge_app/src/hooks/user_hook_config_loader.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::path::Path; - -use forge_domain::{UserHookConfig, UserSettings}; -use tracing::{debug, warn}; - -/// Loads and merges user hook configurations from the three settings file -/// locations. -/// -/// Resolution order (all merged, not overridden): -/// 1. `~/.forge/settings.json` (user-level, applies to all projects) -/// 2. `.forge/settings.json` (project-level, committable) -/// 3. `.forge/settings.local.json` (project-level, gitignored) -pub struct UserHookConfigLoader; - -impl UserHookConfigLoader { - /// Loads and merges hook configurations from all settings files. - /// - /// # Arguments - /// * `home` - Home directory (e.g., `/Users/name`). If `None`, user-level - /// settings are skipped. - /// * `cwd` - Current working directory for project-level settings. - /// - /// # Errors - /// This function does not return errors. Invalid or missing files are - /// silently skipped with a debug log. - pub fn load(home: Option<&Path>, cwd: &Path) -> UserHookConfig { - let mut config = UserHookConfig::new(); - - // 1. User-level: ~/.forge/settings.json - if let Some(home) = home { - let user_settings_path = home.join("forge").join("settings.json"); - if let Some(user_config) = Self::load_file(&user_settings_path) { - debug!(path = %user_settings_path.display(), "Loaded user-level hook config"); - config.merge(user_config); - } - } - - // 2. Project-level: .forge/settings.json - let project_settings_path = cwd.join(".forge").join("settings.json"); - if let Some(project_config) = Self::load_file(&project_settings_path) { - debug!(path = %project_settings_path.display(), "Loaded project-level hook config"); - config.merge(project_config); - } - - // 3. Project-local: .forge/settings.local.json - let local_settings_path = cwd.join(".forge").join("settings.local.json"); - if let Some(local_config) = Self::load_file(&local_settings_path) { - debug!(path = %local_settings_path.display(), "Loaded project-local hook config"); - config.merge(local_config); - } - - if !config.is_empty() { - debug!( - event_count = config.events.len(), - "Merged user hook configuration" - ); - } - - config - } - - /// Loads a single settings file and extracts hook configuration. - /// - /// Returns `None` if the file doesn't exist or is invalid. - fn load_file(path: &Path) -> Option { - let contents = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(_) => return None, - }; - - match serde_json::from_str::(&contents) { - Ok(settings) => { - if settings.hooks.is_empty() { - None - } else { - Some(settings.hooks) - } - } - Err(e) => { - warn!( - path = %path.display(), - error = %e, - "Failed to parse settings file for hooks" - ); - None - } - } - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use pretty_assertions::assert_eq; - - use super::*; - - #[test] - fn test_load_nonexistent_paths() { - let home = PathBuf::from("/nonexistent/home"); - let cwd = PathBuf::from("/nonexistent/project"); - - let actual = UserHookConfigLoader::load(Some(&home), &cwd); - assert!(actual.is_empty()); - } - - #[test] - fn test_load_file_valid_settings() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - std::fs::write( - &settings_path, - r#"{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } - ] - } - }"#, - ) - .unwrap(); - - let actual = UserHookConfigLoader::load_file(&settings_path); - assert!(actual.is_some()); - let config = actual.unwrap(); - assert_eq!( - config - .get_groups(&forge_domain::UserHookEventName::PreToolUse) - .len(), - 1 - ); - } - - #[test] - fn test_load_file_settings_without_hooks() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - std::fs::write(&settings_path, r#"{"other_key": "value"}"#).unwrap(); - - let actual = UserHookConfigLoader::load_file(&settings_path); - assert!(actual.is_none()); - } - - #[test] - fn test_load_merges_all_sources() { - // Set up a fake home directory - let home_dir = tempfile::tempdir().unwrap(); - let forge_dir = home_dir.path().join("forge"); - std::fs::create_dir_all(&forge_dir).unwrap(); - std::fs::write( - forge_dir.join("settings.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "global.sh" }] } - ] - } - }"#, - ) - .unwrap(); - - // Set up a project directory - let project_dir = tempfile::tempdir().unwrap(); - let project_forge_dir = project_dir.path().join(".forge"); - std::fs::create_dir_all(&project_forge_dir).unwrap(); - std::fs::write( - project_forge_dir.join("settings.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { "matcher": "Write", "hooks": [{ "type": "command", "command": "project.sh" }] } - ] - } - }"#, - ) - .unwrap(); - std::fs::write( - project_forge_dir.join("settings.local.json"), - r#"{ - "hooks": { - "Stop": [ - { "hooks": [{ "type": "command", "command": "local-stop.sh" }] } - ] - } - }"#, - ) - .unwrap(); - - let actual = UserHookConfigLoader::load(Some(home_dir.path()), project_dir.path()); - - // PreToolUse should have 2 groups (global + project) - assert_eq!( - actual - .get_groups(&forge_domain::UserHookEventName::PreToolUse) - .len(), - 2 - ); - // Stop should have 1 group (local) - assert_eq!( - actual - .get_groups(&forge_domain::UserHookEventName::Stop) - .len(), - 1 - ); - } -} diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index 4c453f4471..e845c7dd40 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -22,8 +22,11 @@ impl UserHookExecutor { /// # Arguments /// * `command` - The shell command string to execute. /// * `input_json` - JSON string to pipe to the command's stdin. - /// * `timeout` - Optional timeout in milliseconds. Uses default (10 min) if - /// `None`. + /// * `timeout` - Optional per-hook timeout in milliseconds. Falls back to + /// `default_timeout_ms` when `None`. + /// * `default_timeout_ms` - Default timeout in milliseconds from the + /// environment configuration. Uses the built-in default (10 min) when + /// zero. /// * `cwd` - Working directory for the command. /// * `env_vars` - Additional environment variables to set. /// @@ -33,12 +36,19 @@ impl UserHookExecutor { command: &str, input_json: &str, timeout: Option, + default_timeout_ms: u64, cwd: &PathBuf, env_vars: &HashMap, ) -> anyhow::Result { let timeout_duration = timeout .map(Duration::from_millis) - .unwrap_or(DEFAULT_HOOK_TIMEOUT); + .unwrap_or_else(|| { + if default_timeout_ms > 0 { + Duration::from_millis(default_timeout_ms) + } else { + DEFAULT_HOOK_TIMEOUT + } + }); debug!( command = command, @@ -134,9 +144,10 @@ mod tests { #[tokio::test] async fn test_execute_simple_command() { let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute("echo hello", "{}", None, &cwd, &HashMap::new()) - .await - .unwrap(); + let actual = + UserHookExecutor::execute("echo hello", "{}", None, 0, &cwd, &HashMap::new()) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(0)); assert_eq!(actual.stdout.trim(), "hello"); @@ -150,6 +161,7 @@ mod tests { "cat", r#"{"hook_event_name": "PreToolUse"}"#, None, + 0, &cwd, &HashMap::new(), ) @@ -167,6 +179,7 @@ mod tests { "echo 'blocked' >&2; exit 2", "{}", None, + 0, &cwd, &HashMap::new(), ) @@ -181,7 +194,7 @@ mod tests { #[tokio::test] async fn test_execute_non_blocking_error() { let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute("exit 1", "{}", None, &cwd, &HashMap::new()) + let actual = UserHookExecutor::execute("exit 1", "{}", None, 0, &cwd, &HashMap::new()) .await .unwrap(); @@ -196,6 +209,7 @@ mod tests { "sleep 10", "{}", Some(100), // 100ms timeout + 0, &cwd, &HashMap::new(), ) @@ -213,9 +227,10 @@ mod tests { let mut env_vars = HashMap::new(); env_vars.insert("FORGE_TEST_VAR".to_string(), "test_value".to_string()); - let actual = UserHookExecutor::execute("echo $FORGE_TEST_VAR", "{}", None, &cwd, &env_vars) - .await - .unwrap(); + let actual = + UserHookExecutor::execute("echo $FORGE_TEST_VAR", "{}", None, 0, &cwd, &env_vars) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(0)); assert_eq!(actual.stdout.trim(), "test_value"); @@ -228,6 +243,7 @@ mod tests { r#"echo '{"decision":"block","reason":"test"}'"#, "{}", None, + 0, &cwd, &HashMap::new(), ) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 4f5dbd0023..59235ad14f 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -24,6 +24,8 @@ pub struct UserHookHandler { config: UserHookConfig, cwd: PathBuf, env_vars: HashMap, + /// Default timeout in milliseconds for hook commands from the environment. + default_hook_timeout: u64, /// Tracks whether a Stop hook has already fired to prevent infinite loops. stop_hook_active: std::sync::Arc, } @@ -37,11 +39,14 @@ impl UserHookHandler { /// * `project_dir` - Project root directory for `FORGE_PROJECT_DIR` env /// var. /// * `session_id` - Current session/conversation ID. + /// * `default_hook_timeout` - Default timeout in milliseconds for hook + /// commands. pub fn new( config: UserHookConfig, cwd: PathBuf, project_dir: PathBuf, session_id: String, + default_hook_timeout: u64, ) -> Self { let mut env_vars = HashMap::new(); env_vars.insert( @@ -55,6 +60,7 @@ impl UserHookHandler { config, cwd, env_vars, + default_hook_timeout, stop_hook_active: std::sync::Arc::new(AtomicBool::new(false)), } } @@ -124,6 +130,7 @@ impl UserHookHandler { command, &input_json, hook.timeout, + self.default_hook_timeout, &self.cwd, &self.env_vars, ) @@ -697,6 +704,7 @@ mod tests { PathBuf::from("/tmp"), PathBuf::from("/tmp"), "sess-1".to_string(), + 0, ); assert!(!handler.has_hooks(&UserHookEventName::PreToolUse)); } @@ -710,6 +718,7 @@ mod tests { PathBuf::from("/tmp"), PathBuf::from("/tmp"), "sess-1".to_string(), + 0, ); assert!(handler.has_hooks(&UserHookEventName::PreToolUse)); assert!(!handler.has_hooks(&UserHookEventName::Stop)); diff --git a/crates/forge_app/src/orch_spec/orch_setup.rs b/crates/forge_app/src/orch_spec/orch_setup.rs index f03ff78aca..3f4d3581bd 100644 --- a/crates/forge_app/src/orch_spec/orch_setup.rs +++ b/crates/forge_app/src/orch_spec/orch_setup.rs @@ -72,6 +72,7 @@ impl Default for TestContext { suppress_retry_errors: Default::default(), }, tool_timeout: 300, + hook_timeout: 600000, max_search_lines: 1000, fetch_truncation_limit: 1024, stdout_max_prefix_length: 256, diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index c11b4fafe4..023e766564 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -487,6 +487,18 @@ pub trait CommandLoaderService: Send + Sync { async fn get_commands(&self) -> anyhow::Result>; } +#[async_trait::async_trait] +pub trait UserHookConfigService: Send + Sync { + /// Loads and merges user hook configurations from all settings file + /// locations. + /// + /// Resolution order (all merged, not overridden): + /// 1. `~/.forge/settings.json` (user-level, applies to all projects) + /// 2. `.forge/settings.json` (project-level, committable) + /// 3. `.forge/settings.local.json` (project-level, gitignored) + async fn get_user_hook_config(&self) -> anyhow::Result; +} + #[async_trait::async_trait] pub trait PolicyService: Send + Sync { /// Check if an operation is allowed and handle user confirmation if needed @@ -566,6 +578,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra { type AuthService: AuthService; type AgentRegistry: AgentRegistry; type CommandLoaderService: CommandLoaderService; + type UserHookConfigService: UserHookConfigService; type PolicyService: PolicyService; type ProviderAuthService: ProviderAuthService; type WorkspaceService: WorkspaceService; @@ -594,6 +607,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra { fn auth_service(&self) -> &Self::AuthService; fn agent_registry(&self) -> &Self::AgentRegistry; fn command_loader_service(&self) -> &Self::CommandLoaderService; + fn user_hook_config_service(&self) -> &Self::UserHookConfigService; fn policy_service(&self) -> &Self::PolicyService; fn provider_auth_service(&self) -> &Self::ProviderAuthService; fn workspace_service(&self) -> &Self::WorkspaceService; @@ -931,6 +945,15 @@ impl CommandLoaderService for I { } } +#[async_trait::async_trait] +impl UserHookConfigService for I { + async fn get_user_hook_config(&self) -> anyhow::Result { + self.user_hook_config_service() + .get_user_hook_config() + .await + } +} + #[async_trait::async_trait] impl PolicyService for I { async fn check_operation_permission( diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index fa2331e690..be3fc7fe49 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -23,6 +23,7 @@ sem_search_top_k = 10 services_url = "https://api.forgecode.dev/" tool_supported = true tool_timeout_secs = 300 +hook_timeout_ms = 600000 top_k = 30 top_p = 0.8 diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 366633054e..ae5f9500c8 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -60,6 +60,10 @@ pub struct ForgeConfig { /// cancelled. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_timeout_secs: Option, + /// Default timeout in milliseconds for user hook commands. + /// Individual hooks can override this via their own `timeout` field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hook_timeout_ms: Option, /// Whether to automatically open HTML dump files in the browser after /// creation. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/forge_domain/src/env.rs b/crates/forge_domain/src/env.rs index 1db2b2903c..8d8f9eb1f3 100644 --- a/crates/forge_domain/src/env.rs +++ b/crates/forge_domain/src/env.rs @@ -95,6 +95,10 @@ pub struct Environment { /// Maximum execution time in seconds for a single tool call. /// Controls how long a tool can run before being terminated. pub tool_timeout: u64, + /// Default timeout in milliseconds for user hook commands. + /// Individual hooks can override this via their own `timeout` field. + /// Controlled by FORGE_HOOK_TIMEOUT_MS environment variable. + pub hook_timeout: u64, /// Whether to automatically open HTML dump files in the browser. /// Controlled by FORGE_DUMP_AUTO_OPEN environment variable. pub auto_open_dump: bool, diff --git a/crates/forge_infra/src/env.rs b/crates/forge_infra/src/env.rs index aeb5882838..334f86cc1b 100644 --- a/crates/forge_infra/src/env.rs +++ b/crates/forge_infra/src/env.rs @@ -148,6 +148,7 @@ fn to_environment(fc: ForgeConfig, cwd: PathBuf) -> Environment { max_file_size: fc.max_file_size_bytes.unwrap_or_default(), max_image_size: fc.max_image_size_bytes.unwrap_or_default(), tool_timeout: fc.tool_timeout_secs.unwrap_or_default(), + hook_timeout: fc.hook_timeout_ms.unwrap_or_default(), auto_open_dump: fc.auto_open_dump.unwrap_or_default(), debug_requests: fc.debug_requests, custom_history_path: fc.custom_history_path, @@ -314,6 +315,7 @@ fn to_forge_config(env: &Environment) -> ForgeConfig { fc.max_file_size_bytes = Some(env.max_file_size); fc.max_image_size_bytes = Some(env.max_image_size); fc.tool_timeout_secs = Some(env.tool_timeout); + fc.hook_timeout_ms = Some(env.hook_timeout); fc.auto_open_dump = Some(env.auto_open_dump); fc.debug_requests = env.debug_requests.clone(); fc.custom_history_path = env.custom_history_path.clone(); diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index f732a2d6dc..914c404222 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -388,6 +388,7 @@ impl From<&Environment> for Info { .add_key_value("ForgeCode Service URL", env.service_url.to_string()) .add_title("TOOL CONFIGURATION") .add_key_value("Tool Timeout", format!("{}s", env.tool_timeout)) + .add_key_value("Hook Timeout", format!("{}ms", env.hook_timeout)) .add_key_value("Max Image Size", format!("{} bytes", env.max_image_size)) .add_key_value("Auto Open Dump", env.auto_open_dump.to_string()) .add_key_value( diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 17e1a1f474..6accd181c5 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -24,6 +24,7 @@ use crate::mcp::{ForgeMcpManager, ForgeMcpService}; use crate::policy::ForgePolicyService; use crate::provider_service::ForgeProviderService; use crate::template::ForgeTemplateService; +use crate::user_hook_config::ForgeUserHookConfigService; use crate::tool_services::{ ForgeFetch, ForgeFollowup, ForgeFsPatch, ForgeFsRead, ForgeFsRemove, ForgeFsSearch, ForgeFsUndo, ForgeFsWrite, ForgeImageRead, ForgePlanCreate, ForgeShell, ForgeSkillFetch, @@ -79,6 +80,7 @@ pub struct ForgeServices< auth_service: Arc>, agent_registry_service: Arc>, command_loader_service: Arc>, + user_hook_config_service: Arc>, policy_service: ForgePolicyService, provider_auth_service: ForgeProviderAuthService, workspace_service: Arc>>, @@ -134,6 +136,7 @@ impl< Arc::new(ForgeCustomInstructionsService::new(infra.clone())); let agent_registry_service = Arc::new(ForgeAgentRegistryService::new(infra.clone())); let command_loader_service = Arc::new(ForgeCommandLoaderService::new(infra.clone())); + let user_hook_config_service = Arc::new(ForgeUserHookConfigService::new(infra.clone())); let policy_service = ForgePolicyService::new(infra.clone()); let provider_auth_service = ForgeProviderAuthService::new(infra.clone()); let discovery = Arc::new(FdDefault::new(infra.clone())); @@ -166,6 +169,7 @@ impl< config_service, agent_registry_service, command_loader_service, + user_hook_config_service, policy_service, provider_auth_service, workspace_service, @@ -233,6 +237,7 @@ impl< type AuthService = AuthService; type AgentRegistry = ForgeAgentRegistryService; type CommandLoaderService = ForgeCommandLoaderService; + type UserHookConfigService = ForgeUserHookConfigService; type PolicyService = ForgePolicyService; type ProviderService = ForgeProviderService; type WorkspaceService = crate::context_engine::ForgeWorkspaceService>; @@ -322,6 +327,10 @@ impl< &self.command_loader_service } + fn user_hook_config_service(&self) -> &Self::UserHookConfigService { + &self.user_hook_config_service + } + fn policy_service(&self) -> &Self::PolicyService { &self.policy_service } diff --git a/crates/forge_services/src/lib.rs b/crates/forge_services/src/lib.rs index e5ecc37452..58dce89cbe 100644 --- a/crates/forge_services/src/lib.rs +++ b/crates/forge_services/src/lib.rs @@ -21,6 +21,7 @@ mod provider_service; mod range; mod template; mod tool_services; +mod user_hook_config; mod utils; pub use app_config::*; diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index eba90d388d..95089704f3 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -97,6 +97,8 @@ impl forge_app::UserHookConfigService mod tests { use std::path::PathBuf; + use fake::Fake; + use forge_app::UserHookConfigService; use pretty_assertions::assert_eq; use super::*; @@ -117,11 +119,7 @@ mod tests { ) .unwrap(); - let infra = TestInfra { - home: None, - cwd: PathBuf::from("/nonexistent"), - }; - let service = ForgeUserHookConfigService::new(Arc::new(infra)); + let service = fixture(None, PathBuf::from("/nonexistent")); let actual = service.load_file(&settings_path).await; assert!(actual.is_some()); @@ -140,11 +138,7 @@ mod tests { let settings_path = dir.path().join("settings.json"); std::fs::write(&settings_path, r#"{"other_key": "value"}"#).unwrap(); - let infra = TestInfra { - home: None, - cwd: PathBuf::from("/nonexistent"), - }; - let service = ForgeUserHookConfigService::new(Arc::new(infra)); + let service = fixture(None, PathBuf::from("/nonexistent")); let actual = service.load_file(&settings_path).await; assert!(actual.is_none()); @@ -152,11 +146,10 @@ mod tests { #[tokio::test] async fn test_get_user_hook_config_nonexistent_paths() { - let infra = TestInfra { - home: Some(PathBuf::from("/nonexistent/home")), - cwd: PathBuf::from("/nonexistent/project"), - }; - let service = ForgeUserHookConfigService::new(Arc::new(infra)); + let service = fixture( + Some(PathBuf::from("/nonexistent/home")), + PathBuf::from("/nonexistent/project"), + ); let actual = service.get_user_hook_config().await.unwrap(); assert!(actual.is_empty()); @@ -207,11 +200,10 @@ mod tests { ) .unwrap(); - let infra = TestInfra { - home: Some(home_dir.path().to_path_buf()), - cwd: project_dir.path().to_path_buf(), - }; - let service = ForgeUserHookConfigService::new(Arc::new(infra)); + let service = fixture( + Some(home_dir.path().to_path_buf()), + project_dir.path().to_path_buf(), + ); let actual = service.get_user_hook_config().await.unwrap(); @@ -231,7 +223,14 @@ mod tests { ); } - // --- Test infrastructure --- + // --- Test helpers --- + + fn fixture( + home: Option, + cwd: PathBuf, + ) -> ForgeUserHookConfigService { + ForgeUserHookConfigService::new(Arc::new(TestInfra { home, cwd })) + } struct TestInfra { home: Option, @@ -244,11 +243,24 @@ mod tests { Ok(tokio::fs::read_to_string(path).await?) } - async fn read_utf8_batch( + fn read_batch_utf8( &self, - _paths: Vec, _batch_size: usize, - ) -> forge_stream::MpscStream<(PathBuf, anyhow::Result)> { + _paths: Vec, + ) -> impl futures::Stream)> + Send { + futures::stream::empty() + } + + async fn read(&self, path: &Path) -> anyhow::Result> { + Ok(tokio::fs::read(path).await?) + } + + async fn range_read_utf8( + &self, + _path: &Path, + _start_line: u64, + _end_line: u64, + ) -> anyhow::Result<(String, forge_domain::FileInfo)> { unimplemented!("not needed for tests") } } @@ -257,13 +269,23 @@ mod tests { fn get_env_var(&self, _key: &str) -> Option { None } + fn get_env_vars(&self) -> std::collections::BTreeMap { Default::default() } + fn get_environment(&self) -> forge_domain::Environment { - forge_domain::Environment::default() - .home(self.home.clone()) - .cwd(self.cwd.clone()) + let mut env: forge_domain::Environment = fake::Faker.fake(); + env.home = self.home.clone(); + env.cwd = self.cwd.clone(); + env + } + + async fn update_environment( + &self, + _ops: Vec, + ) -> anyhow::Result<()> { + unimplemented!("not needed for tests") } } } diff --git a/forge.schema.json b/forge.schema.json index b529d71e07..b75063a019 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -58,6 +58,15 @@ "null" ] }, + "hook_timeout_ms": { + "description": "Default timeout in milliseconds for user hook commands.\nIndividual hooks can override this via their own `timeout` field.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, "http": { "description": "HTTP client settings including proxy, TLS, and timeout configuration.", "anyOf": [ From 20cb9ad4c22e25c24fd671f38971391266f6ae8a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:54:41 +0000 Subject: [PATCH 07/64] [autofix.ci] apply automated fixes --- .../forge_app/src/hooks/user_hook_executor.rs | 23 ++++++++----------- crates/forge_app/src/services.rs | 4 +--- crates/forge_services/src/forge_services.rs | 2 +- crates/forge_services/src/user_hook_config.rs | 5 +--- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index e845c7dd40..a33e7a63b8 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -40,15 +40,13 @@ impl UserHookExecutor { cwd: &PathBuf, env_vars: &HashMap, ) -> anyhow::Result { - let timeout_duration = timeout - .map(Duration::from_millis) - .unwrap_or_else(|| { - if default_timeout_ms > 0 { - Duration::from_millis(default_timeout_ms) - } else { - DEFAULT_HOOK_TIMEOUT - } - }); + let timeout_duration = timeout.map(Duration::from_millis).unwrap_or_else(|| { + if default_timeout_ms > 0 { + Duration::from_millis(default_timeout_ms) + } else { + DEFAULT_HOOK_TIMEOUT + } + }); debug!( command = command, @@ -144,10 +142,9 @@ mod tests { #[tokio::test] async fn test_execute_simple_command() { let cwd = std::env::current_dir().unwrap(); - let actual = - UserHookExecutor::execute("echo hello", "{}", None, 0, &cwd, &HashMap::new()) - .await - .unwrap(); + let actual = UserHookExecutor::execute("echo hello", "{}", None, 0, &cwd, &HashMap::new()) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(0)); assert_eq!(actual.stdout.trim(), "hello"); diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 023e766564..86b82433fa 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -948,9 +948,7 @@ impl CommandLoaderService for I { #[async_trait::async_trait] impl UserHookConfigService for I { async fn get_user_hook_config(&self) -> anyhow::Result { - self.user_hook_config_service() - .get_user_hook_config() - .await + self.user_hook_config_service().get_user_hook_config().await } } diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 6accd181c5..34a0b26eed 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -24,11 +24,11 @@ use crate::mcp::{ForgeMcpManager, ForgeMcpService}; use crate::policy::ForgePolicyService; use crate::provider_service::ForgeProviderService; use crate::template::ForgeTemplateService; -use crate::user_hook_config::ForgeUserHookConfigService; use crate::tool_services::{ ForgeFetch, ForgeFollowup, ForgeFsPatch, ForgeFsRead, ForgeFsRemove, ForgeFsSearch, ForgeFsUndo, ForgeFsWrite, ForgeImageRead, ForgePlanCreate, ForgeShell, ForgeSkillFetch, }; +use crate::user_hook_config::ForgeUserHookConfigService; type McpService = ForgeMcpService, F, ::Client>; type AuthService = ForgeAuthService; diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index 95089704f3..3ae4c6d89c 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -225,10 +225,7 @@ mod tests { // --- Test helpers --- - fn fixture( - home: Option, - cwd: PathBuf, - ) -> ForgeUserHookConfigService { + fn fixture(home: Option, cwd: PathBuf) -> ForgeUserHookConfigService { ForgeUserHookConfigService::new(Arc::new(TestInfra { home, cwd })) } From 397bb4d0542d83a22727908e085524c70c114e6b Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:22:52 -0400 Subject: [PATCH 08/64] feat(hooks): add HookError chat response and surface it in ui and orchestrator --- crates/forge_app/src/agent_executor.rs | 1 + crates/forge_app/src/hooks/user_hook_handler.rs | 2 ++ crates/forge_app/src/orch.rs | 9 +++++++-- crates/forge_domain/src/chat_response.rs | 7 +++++++ crates/forge_main/src/ui.rs | 8 ++++++++ 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/forge_app/src/agent_executor.rs b/crates/forge_app/src/agent_executor.rs index 4c5ed94ff2..37e761742e 100644 --- a/crates/forge_app/src/agent_executor.rs +++ b/crates/forge_app/src/agent_executor.rs @@ -94,6 +94,7 @@ impl AgentExecutor { ChatResponse::ToolCallStart { .. } => ctx.send(message).await?, ChatResponse::ToolCallEnd(_) => ctx.send(message).await?, ChatResponse::RetryAttempt { .. } => ctx.send(message).await?, + ChatResponse::HookError { .. } => ctx.send(message).await?, ChatResponse::Interrupt { reason } => { return Err(Error::AgentToolInterrupted(reason)) .context(format!( diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 59235ad14f..11d8e704e0 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -327,6 +327,8 @@ impl EventHandle> for UserHookHandler { } let tool_name = event.payload.tool_call.name.as_str(); + // TODO: Add a tool name transformer to map tool names to Forge + // equivalents (e.g. "Bash" → "shell") so that hook configs written let groups = self.config.get_groups(&UserHookEventName::PreToolUse); let hooks = Self::find_matching_hooks(groups, Some(tool_name)); diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index a3a16642f8..c611b12038 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -97,8 +97,13 @@ impl Orchestrator { .await; let tool_result = if let Err(hook_err) = hook_result { - // Hook blocked this tool call — produce an error ToolResult - // so the model sees feedback without aborting the session. + // Hook blocked this tool call — notify the UI and produce an + // error ToolResult so the model sees feedback without aborting. + self.send(ChatResponse::HookError { + tool_name: tool_call.name.clone(), + reason: hook_err.to_string(), + }) + .await?; ToolResult::from(tool_call.clone()).failure(hook_err) } else { // Execute the tool normally diff --git a/crates/forge_domain/src/chat_response.rs b/crates/forge_domain/src/chat_response.rs index e24cd9d731..8017caeab7 100644 --- a/crates/forge_domain/src/chat_response.rs +++ b/crates/forge_domain/src/chat_response.rs @@ -65,6 +65,13 @@ pub enum ChatResponse { notifier: Arc, }, ToolCallEnd(ToolResult), + /// A user-configured hook blocked execution of a tool call. + HookError { + /// Name of the tool that was blocked. + tool_name: ToolName, + /// Human-readable reason provided by the hook (from stderr or JSON output). + reason: String, + }, RetryAttempt { cause: Cause, duration: Duration, diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index ec0cd1a38a..d426fcfccc 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3174,6 +3174,14 @@ impl A + Send + Sync> UI { self.writeln_title(TitleFormat::error(cause.as_str()))?; } } + ChatResponse::HookError { tool_name, reason } => { + writer.finish()?; + self.spinner.stop(None)?; + self.writeln_title(TitleFormat::error(format!( + "PreToolUse:{tool_name} hook error: {reason}" + )))?; + self.spinner.start(None)?; + } ChatResponse::Interrupt { reason } => { writer.finish()?; self.spinner.stop(None)?; From 8e060d6fef3a7d5dbeef38b87eb098022c3ea0b6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:25:06 +0000 Subject: [PATCH 09/64] [autofix.ci] apply automated fixes --- crates/forge_domain/src/chat_response.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/forge_domain/src/chat_response.rs b/crates/forge_domain/src/chat_response.rs index 8017caeab7..3f5f5347ec 100644 --- a/crates/forge_domain/src/chat_response.rs +++ b/crates/forge_domain/src/chat_response.rs @@ -69,7 +69,8 @@ pub enum ChatResponse { HookError { /// Name of the tool that was blocked. tool_name: ToolName, - /// Human-readable reason provided by the hook (from stderr or JSON output). + /// Human-readable reason provided by the hook (from stderr or JSON + /// output). reason: String, }, RetryAttempt { From 6f50be05143316cc68d7536692d8b16f7beff559 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:42:45 -0400 Subject: [PATCH 10/64] refactor(hooks): replace manual Display impl with strum_macros::Display for UserHookEventName --- crates/forge_domain/src/user_hook_config.rs | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs index 306e0bc088..89dbfb6929 100644 --- a/crates/forge_domain/src/user_hook_config.rs +++ b/crates/forge_domain/src/user_hook_config.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; -use std::fmt; use serde::{Deserialize, Serialize}; +use strum_macros::Display; /// Top-level user hook configuration. /// @@ -52,7 +52,7 @@ impl UserHookConfig { /// Supported hook event names that map to lifecycle points in the /// orchestrator. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] pub enum UserHookEventName { /// Fired before a tool call executes. Can block execution. PreToolUse, @@ -76,23 +76,6 @@ pub enum UserHookEventName { PostCompact, } -impl fmt::Display for UserHookEventName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::PreToolUse => write!(f, "PreToolUse"), - Self::PostToolUse => write!(f, "PostToolUse"), - Self::PostToolUseFailure => write!(f, "PostToolUseFailure"), - Self::Stop => write!(f, "Stop"), - Self::Notification => write!(f, "Notification"), - Self::SessionStart => write!(f, "SessionStart"), - Self::SessionEnd => write!(f, "SessionEnd"), - Self::UserPromptSubmit => write!(f, "UserPromptSubmit"), - Self::PreCompact => write!(f, "PreCompact"), - Self::PostCompact => write!(f, "PostCompact"), - } - } -} - /// A matcher group pairs an optional regex matcher with a list of hook /// handlers. /// From 6ae1fb0413cb1a6da64497eb3457a6fefc9606c7 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:19:42 -0400 Subject: [PATCH 11/64] feat(hooks): implement UserPromptSubmit hook event with blocking and feedback injection --- .../forge_app/src/hooks/user_hook_handler.rs | 69 +++++++++++++++++-- crates/forge_domain/src/user_hook_io.rs | 27 ++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 11d8e704e0..96d40fde97 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -4,9 +4,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; use forge_domain::{ - Conversation, EndPayload, EventData, EventHandle, HookEventInput, HookExecutionResult, - HookInput, HookOutput, RequestPayload, ResponsePayload, StartPayload, ToolcallEndPayload, - ToolcallStartPayload, UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, + ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, + HookExecutionResult, HookInput, HookOutput, RequestPayload, ResponsePayload, Role, StartPayload, + ToolcallEndPayload, ToolcallStartPayload, UserHookConfig, UserHookEntry, UserHookEventName, + UserHookMatcherGroup, }; use regex::Regex; use tracing::{debug, warn}; @@ -295,10 +296,66 @@ impl EventHandle> for UserHookHandler { impl EventHandle> for UserHookHandler { async fn handle( &self, - _event: &EventData, - _conversation: &mut Conversation, + event: &EventData, + conversation: &mut Conversation, ) -> anyhow::Result<()> { - // No user hook events map to Request currently + // Only fire on the first request of a turn (user-submitted prompt). + // Subsequent iterations are internal LLM retry/tool-call loops and + // should not re-trigger UserPromptSubmit. + if event.payload.request_count != 0 { + return Ok(()); + } + + if !self.has_hooks(&UserHookEventName::UserPromptSubmit) { + return Ok(()); + } + + let groups = self.config.get_groups(&UserHookEventName::UserPromptSubmit); + let hooks = Self::find_matching_hooks(groups, None); + + if hooks.is_empty() { + return Ok(()); + } + + // Extract the last user message text as the prompt sent to the hook. + let prompt = conversation + .context + .as_ref() + .and_then(|ctx| { + ctx.messages + .iter() + .rev() + .find(|m| m.has_role(Role::User)) + .and_then(|m| m.content()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + + let input = HookInput { + hook_event_name: "UserPromptSubmit".to_string(), + cwd: self.cwd.to_string_lossy().to_string(), + session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), + event_data: HookEventInput::UserPromptSubmit { prompt }, + }; + + let results = self.execute_hooks(&hooks, &input).await; + + if let Some(reason) = Self::process_results(&results) { + debug!( + reason = reason.as_str(), + "UserPromptSubmit hook blocked with feedback" + ); + // Inject feedback so the model sees why the prompt was flagged. + if let Some(context) = conversation.context.as_mut() { + let feedback_msg = format!( + "\nUserPromptSubmit\nblocked\n{reason}\n" + ); + context + .messages + .push(ContextMessage::user(feedback_msg, None).into()); + } + } + Ok(()) } } diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 115f62a673..a48145fbf2 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -60,6 +60,11 @@ pub enum HookEventInput { /// Source of the session start (e.g., "startup", "resume"). source: String, }, + /// Input for UserPromptSubmit events. + UserPromptSubmit { + /// The raw prompt text submitted by the user. + prompt: String, + }, /// Empty input for events that don't need event-specific data. Empty {}, } @@ -223,6 +228,28 @@ mod tests { assert_eq!(actual["stop_hook_active"], false); } + #[test] + fn test_hook_input_serialization_user_prompt_submit() { + let fixture = HookInput { + hook_event_name: "UserPromptSubmit".to_string(), + cwd: "/project".to_string(), + session_id: Some("sess-abc".to_string()), + event_data: HookEventInput::UserPromptSubmit { + prompt: "fix the bug".to_string(), + }, + }; + + let actual = serde_json::to_value(&fixture).unwrap(); + + assert_eq!(actual["hook_event_name"], "UserPromptSubmit"); + assert_eq!(actual["cwd"], "/project"); + assert_eq!(actual["session_id"], "sess-abc"); + assert_eq!(actual["prompt"], "fix the bug"); + // No tool_name, stop_hook_active, or other variant fields present + assert!(actual["tool_name"].is_null()); + assert!(actual["stop_hook_active"].is_null()); + } + #[test] fn test_hook_output_parse_valid_json() { let stdout = r#"{"decision": "block", "reason": "unsafe command"}"#; From 156cbc8ed9a2621c9129e6e5dcca9f3927268157 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:21:28 +0000 Subject: [PATCH 12/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/user_hook_handler.rs | 6 +++--- crates/forge_domain/src/user_hook_io.rs | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 96d40fde97..df63959994 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -5,9 +5,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, - HookExecutionResult, HookInput, HookOutput, RequestPayload, ResponsePayload, Role, StartPayload, - ToolcallEndPayload, ToolcallStartPayload, UserHookConfig, UserHookEntry, UserHookEventName, - UserHookMatcherGroup, + HookExecutionResult, HookInput, HookOutput, RequestPayload, ResponsePayload, Role, + StartPayload, ToolcallEndPayload, ToolcallStartPayload, UserHookConfig, UserHookEntry, + UserHookEventName, UserHookMatcherGroup, }; use regex::Regex; use tracing::{debug, warn}; diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index a48145fbf2..3f260428c7 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -234,9 +234,7 @@ mod tests { hook_event_name: "UserPromptSubmit".to_string(), cwd: "/project".to_string(), session_id: Some("sess-abc".to_string()), - event_data: HookEventInput::UserPromptSubmit { - prompt: "fix the bug".to_string(), - }, + event_data: HookEventInput::UserPromptSubmit { prompt: "fix the bug".to_string() }, }; let actual = serde_json::to_value(&fixture).unwrap(); From cb100b4c059b68ffd7bb93473d870b819e6f6761 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:45:03 -0400 Subject: [PATCH 13/64] feat(hooks): add ForgeHookCommandService wrapping CommandInfra for hook execution --- .../src/tool_services/hook_command.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 crates/forge_services/src/tool_services/hook_command.rs diff --git a/crates/forge_services/src/tool_services/hook_command.rs b/crates/forge_services/src/tool_services/hook_command.rs new file mode 100644 index 0000000000..b4a2f84526 --- /dev/null +++ b/crates/forge_services/src/tool_services/hook_command.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use forge_app::{CommandInfra, HookCommandService}; +use forge_domain::CommandOutput; + +/// Thin wrapper around [`CommandInfra::execute_command_with_input`] that +/// satisfies the [`HookCommandService`] contract. +/// +/// By delegating to the underlying infra this service avoids duplicating +/// process-spawning, stdin-piping, and timeout logic; those concerns live +/// entirely inside the `CommandInfra` implementation. +#[derive(Clone)] +pub struct ForgeHookCommandService(Arc); + +impl ForgeHookCommandService { + /// Creates a new `ForgeHookCommandService` backed by the given infra. + pub fn new(infra: Arc) -> Self { + Self(infra) + } +} + +#[async_trait::async_trait] +impl HookCommandService for ForgeHookCommandService { + async fn execute_command_with_input( + &self, + command: String, + working_dir: PathBuf, + stdin_input: String, + timeout: Duration, + env_vars: HashMap, + ) -> anyhow::Result { + self.0 + .execute_command_with_input(command, working_dir, stdin_input, timeout, env_vars) + .await + } +} From ab08745b8fc748d4cbc9b2537beeb5824d8f666c Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:45:07 -0400 Subject: [PATCH 14/64] refactor(hooks): inject HookCommandService into UserHookExecutor and UserHookHandler --- crates/forge_app/src/app.rs | 1 + .../forge_app/src/hooks/user_hook_executor.rs | 312 +++++++++--------- .../forge_app/src/hooks/user_hook_handler.rs | 153 ++++++--- crates/forge_app/src/infra.rs | 28 +- crates/forge_app/src/services.rs | 34 ++ crates/forge_infra/src/executor.rs | 60 +++- crates/forge_infra/src/forge_infra.rs | 16 +- crates/forge_repo/src/forge_repo.rs | 16 +- crates/forge_services/src/forge_services.rs | 11 +- .../forge_services/src/tool_services/mod.rs | 2 + .../forge_services/src/tool_services/shell.rs | 16 + 11 files changed, 431 insertions(+), 218 deletions(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index a9434e2f79..27c2efe2e6 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -145,6 +145,7 @@ impl ForgeApp { let hook = if !user_hook_config.is_empty() { let user_handler = UserHookHandler::new( + services.hook_command_service().clone(), user_hook_config, environment.cwd.clone(), environment.cwd.clone(), diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index a33e7a63b8..5d691847b9 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -3,19 +3,29 @@ use std::path::PathBuf; use std::time::Duration; use forge_domain::HookExecutionResult; -use tokio::io::AsyncWriteExt; -use tracing::{debug, warn}; +use tracing::debug; + +use crate::services::HookCommandService; /// Default timeout for hook commands (10 minutes). const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(600); -/// Executes user hook shell commands with stdin piping and timeout support. +/// Executes user hook commands by delegating to a [`HookCommandService`]. /// -/// Uses `tokio::process::Command` directly (not `CommandInfra`) because we -/// need stdin piping which the existing infrastructure doesn't support. -pub struct UserHookExecutor; +/// Holds the service by value; the service itself is responsible for any +/// internal reference counting (`Arc`). Keeps hook-specific timeout resolution +/// in one place. +#[derive(Clone)] +pub struct UserHookExecutor(S); + +impl UserHookExecutor { + /// Creates a new `UserHookExecutor` backed by the given service. + pub fn new(service: S) -> Self { + Self(service) + } +} -impl UserHookExecutor { +impl UserHookExecutor { /// Executes a shell command, piping `input_json` to stdin and capturing /// stdout/stderr. /// @@ -33,6 +43,7 @@ impl UserHookExecutor { /// # Errors /// Returns an error if the process cannot be spawned. pub async fn execute( + &self, command: &str, input_json: &str, timeout: Option, @@ -55,133 +66,135 @@ impl UserHookExecutor { "Executing user hook command" ); - let shell = if cfg!(target_os = "windows") { - "cmd" - } else { - "sh" - }; - let shell_arg = if cfg!(target_os = "windows") { - "/C" - } else { - "-c" - }; - - let mut child = tokio::process::Command::new(shell) - .arg(shell_arg) - .arg(command) - .current_dir(cwd) - .envs(env_vars) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn()?; - - // Pipe JSON input to stdin - if let Some(mut stdin) = child.stdin.take() { - let input = input_json.to_string(); - tokio::spawn(async move { - let _ = stdin.write_all(input.as_bytes()).await; - let _ = stdin.shutdown().await; - }); - } + let output = self + .0 + .execute_command_with_input( + command.to_string(), + cwd.clone(), + input_json.to_string(), + timeout_duration, + env_vars.clone(), + ) + .await?; - // Wait for the command with timeout. - // Note: `wait_with_output()` takes ownership of `child`. On timeout, - // the future is dropped, and tokio will clean up the child process. - let result = tokio::time::timeout(timeout_duration, child.wait_with_output()).await; - - match result { - Ok(Ok(output)) => { - let exit_code = output.status.code(); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - debug!( - command = command, - exit_code = ?exit_code, - stdout_len = stdout.len(), - stderr_len = stderr.len(), - "Hook command completed" - ); - - Ok(HookExecutionResult { exit_code, stdout, stderr }) - } - Ok(Err(e)) => { - warn!(command = command, error = %e, "Hook command failed to execute"); - Err(e.into()) - } - Err(_) => { - warn!( - command = command, - timeout_ms = timeout_duration.as_millis() as u64, - "Hook command timed out" - ); - // Process is already consumed by wait_with_output, tokio - // handles cleanup when the future is dropped. - Ok(HookExecutionResult { - exit_code: None, - stdout: String::new(), - stderr: format!( - "Hook command timed out after {}ms", - timeout_duration.as_millis() - ), - }) - } - } + debug!( + command = command, + exit_code = ?output.exit_code, + stdout_len = output.stdout.len(), + stderr_len = output.stderr.len(), + "Hook command completed" + ); + + Ok(HookExecutionResult { + exit_code: output.exit_code, + stdout: output.stdout, + stderr: output.stderr, + }) } } #[cfg(test)] mod tests { use std::collections::HashMap; + use std::path::PathBuf; + use std::time::Duration; + use forge_domain::CommandOutput; use pretty_assertions::assert_eq; use super::*; - #[tokio::test] - async fn test_execute_simple_command() { - let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute("echo hello", "{}", None, 0, &cwd, &HashMap::new()) - .await - .unwrap(); + /// A minimal service stub that records calls and returns a fixed result. + #[derive(Clone)] + struct StubInfra { + result: CommandOutput, + } - assert_eq!(actual.exit_code, Some(0)); - assert_eq!(actual.stdout.trim(), "hello"); - assert!(actual.is_success()); + impl StubInfra { + fn success(stdout: &str) -> Self { + Self { + result: CommandOutput { + command: String::new(), + exit_code: Some(0), + stdout: stdout.to_string(), + stderr: String::new(), + }, + } + } + + fn exit(code: i32, stderr: &str) -> Self { + Self { + result: CommandOutput { + command: String::new(), + exit_code: Some(code), + stdout: String::new(), + stderr: stderr.to_string(), + }, + } + } + + fn timeout() -> Self { + Self { + result: CommandOutput { + command: String::new(), + exit_code: None, + stdout: String::new(), + stderr: "Hook command timed out after 100ms".to_string(), + }, + } + } + } + + #[async_trait::async_trait] + impl HookCommandService for StubInfra { + async fn execute_command_with_input( + &self, + command: String, + _working_dir: PathBuf, + _stdin_input: String, + _timeout: Duration, + _env_vars: HashMap, + ) -> anyhow::Result { + let mut out = self.result.clone(); + out.command = command; + Ok(out) + } } #[tokio::test] - async fn test_execute_reads_stdin() { - let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute( - "cat", - r#"{"hook_event_name": "PreToolUse"}"#, - None, - 0, - &cwd, - &HashMap::new(), - ) - .await - .unwrap(); + async fn test_execute_success() { + let fixture = UserHookExecutor::new(StubInfra::success("hello")); + let actual = fixture + .execute( + "echo hello", + "{}", + None, + 0, + &std::env::current_dir().unwrap(), + &HashMap::new(), + ) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(0)); - assert!(actual.stdout.contains("PreToolUse")); + assert_eq!(actual.stdout, "hello"); + assert!(actual.is_success()); } #[tokio::test] async fn test_execute_exit_code_2() { - let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute( - "echo 'blocked' >&2; exit 2", - "{}", - None, - 0, - &cwd, - &HashMap::new(), - ) - .await - .unwrap(); + let fixture = UserHookExecutor::new(StubInfra::exit(2, "blocked")); + let actual = fixture + .execute( + "exit 2", + "{}", + None, + 0, + &std::env::current_dir().unwrap(), + &HashMap::new(), + ) + .await + .unwrap(); assert_eq!(actual.exit_code, Some(2)); assert!(actual.is_blocking_exit()); @@ -190,8 +203,16 @@ mod tests { #[tokio::test] async fn test_execute_non_blocking_error() { - let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute("exit 1", "{}", None, 0, &cwd, &HashMap::new()) + let fixture = UserHookExecutor::new(StubInfra::exit(1, "")); + let actual = fixture + .execute( + "exit 1", + "{}", + None, + 0, + &std::env::current_dir().unwrap(), + &HashMap::new(), + ) .await .unwrap(); @@ -201,55 +222,20 @@ mod tests { #[tokio::test] async fn test_execute_timeout() { - let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute( - "sleep 10", - "{}", - Some(100), // 100ms timeout - 0, - &cwd, - &HashMap::new(), - ) - .await - .unwrap(); - - // Should have no exit code (killed by timeout) - assert!(actual.exit_code.is_none() || actual.is_non_blocking_error()); - assert!(actual.stderr.contains("timed out")); - } - - #[tokio::test] - async fn test_execute_with_env_vars() { - let cwd = std::env::current_dir().unwrap(); - let mut env_vars = HashMap::new(); - env_vars.insert("FORGE_TEST_VAR".to_string(), "test_value".to_string()); - - let actual = - UserHookExecutor::execute("echo $FORGE_TEST_VAR", "{}", None, 0, &cwd, &env_vars) - .await - .unwrap(); - - assert_eq!(actual.exit_code, Some(0)); - assert_eq!(actual.stdout.trim(), "test_value"); - } - - #[tokio::test] - async fn test_execute_json_output() { - let cwd = std::env::current_dir().unwrap(); - let actual = UserHookExecutor::execute( - r#"echo '{"decision":"block","reason":"test"}'"#, - "{}", - None, - 0, - &cwd, - &HashMap::new(), - ) - .await - .unwrap(); + let fixture = UserHookExecutor::new(StubInfra::timeout()); + let actual = fixture + .execute( + "sleep 10", + "{}", + Some(100), + 0, + &std::env::current_dir().unwrap(), + &HashMap::new(), + ) + .await + .unwrap(); - assert!(actual.is_success()); - let output = actual.parse_output().unwrap(); - assert!(output.is_blocking()); - assert_eq!(output.reason, Some("test".to_string())); + assert!(actual.exit_code.is_none()); + assert!(actual.stderr.contains("timed out")); } } diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index df63959994..486f4d2295 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -13,6 +13,7 @@ use regex::Regex; use tracing::{debug, warn}; use super::user_hook_executor::UserHookExecutor; +use crate::services::HookCommandService; /// EventHandle implementation that bridges user-configured hooks with the /// existing lifecycle event system. @@ -20,8 +21,8 @@ use super::user_hook_executor::UserHookExecutor; /// This handler is constructed from a `UserHookConfig` and executes matching /// hook commands at each lifecycle event point. It wires into the existing /// `Hook` system via `Hook::zip()`. -#[derive(Clone)] -pub struct UserHookHandler { +pub struct UserHookHandler { + executor: UserHookExecutor, config: UserHookConfig, cwd: PathBuf, env_vars: HashMap, @@ -31,10 +32,24 @@ pub struct UserHookHandler { stop_hook_active: std::sync::Arc, } -impl UserHookHandler { +impl Clone for UserHookHandler { + fn clone(&self) -> Self { + Self { + executor: self.executor.clone(), + config: self.config.clone(), + cwd: self.cwd.clone(), + env_vars: self.env_vars.clone(), + default_hook_timeout: self.default_hook_timeout, + stop_hook_active: self.stop_hook_active.clone(), + } + } +} + +impl UserHookHandler { /// Creates a new user hook handler from configuration. /// /// # Arguments + /// * `service` - The hook command service used to execute hook commands. /// * `config` - The merged user hook configuration. /// * `cwd` - Current working directory for command execution. /// * `project_dir` - Project root directory for `FORGE_PROJECT_DIR` env @@ -43,6 +58,7 @@ impl UserHookHandler { /// * `default_hook_timeout` - Default timeout in milliseconds for hook /// commands. pub fn new( + service: I, config: UserHookConfig, cwd: PathBuf, project_dir: PathBuf, @@ -58,6 +74,7 @@ impl UserHookHandler { env_vars.insert("FORGE_CWD".to_string(), cwd.to_string_lossy().to_string()); Self { + executor: UserHookExecutor::new(service), config, cwd, env_vars, @@ -115,7 +132,10 @@ impl UserHookHandler { &self, hooks: &[&UserHookEntry], input: &HookInput, - ) -> Vec { + ) -> Vec + where + I: HookCommandService, + { let input_json = match serde_json::to_string(input) { Ok(json) => json, Err(e) => { @@ -127,15 +147,17 @@ impl UserHookHandler { let mut results = Vec::new(); for hook in hooks { if let Some(command) = &hook.command { - match UserHookExecutor::execute( - command, - &input_json, - hook.timeout, - self.default_hook_timeout, - &self.cwd, - &self.env_vars, - ) - .await + match self + .executor + .execute( + command, + &input_json, + hook.timeout, + self.default_hook_timeout, + &self.cwd, + &self.env_vars, + ) + .await { Ok(result) => results.push(result), Err(e) => { @@ -250,7 +272,7 @@ enum PreToolUseDecision { // --- EventHandle implementations --- #[async_trait] -impl EventHandle> for UserHookHandler { +impl EventHandle> for UserHookHandler { async fn handle( &self, _event: &EventData, @@ -293,7 +315,7 @@ impl EventHandle> for UserHookHandler { } #[async_trait] -impl EventHandle> for UserHookHandler { +impl EventHandle> for UserHookHandler { async fn handle( &self, event: &EventData, @@ -361,7 +383,7 @@ impl EventHandle> for UserHookHandler { } #[async_trait] -impl EventHandle> for UserHookHandler { +impl EventHandle> for UserHookHandler { async fn handle( &self, _event: &EventData, @@ -373,7 +395,9 @@ impl EventHandle> for UserHookHandler { } #[async_trait] -impl EventHandle> for UserHookHandler { +impl EventHandle> + for UserHookHandler +{ async fn handle( &self, event: &EventData, @@ -438,7 +462,9 @@ impl EventHandle> for UserHookHandler { } #[async_trait] -impl EventHandle> for UserHookHandler { +impl EventHandle> + for UserHookHandler +{ async fn handle( &self, event: &EventData, @@ -505,7 +531,7 @@ impl EventHandle> for UserHookHandler { } #[async_trait] -impl EventHandle> for UserHookHandler { +impl EventHandle> for UserHookHandler { async fn handle( &self, _event: &EventData, @@ -582,13 +608,52 @@ impl EventHandle> for UserHookHandler { #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + use std::time::Duration; + use forge_domain::{ - HookExecutionResult, UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType, + CommandOutput, HookExecutionResult, UserHookEntry, UserHookEventName, UserHookMatcherGroup, + UserHookType, }; use pretty_assertions::assert_eq; use super::*; + /// A no-op service stub for tests that only exercise config/matching logic. + #[derive(Clone)] + struct NullInfra; + + #[async_trait::async_trait] + impl HookCommandService for NullInfra { + async fn execute_command_with_input( + &self, + command: String, + _working_dir: PathBuf, + _stdin_input: String, + _timeout: Duration, + _env_vars: HashMap, + ) -> anyhow::Result { + Ok(CommandOutput { + command, + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }) + } + } + + fn null_handler(config: UserHookConfig) -> UserHookHandler { + UserHookHandler::new( + NullInfra, + config, + PathBuf::from("/tmp"), + PathBuf::from("/tmp"), + "sess-1".to_string(), + 0, + ) + } + fn make_entry(command: &str) -> UserHookEntry { UserHookEntry { hook_type: UserHookType::Command, @@ -607,7 +672,7 @@ mod tests { #[test] fn test_find_matching_hooks_no_matcher_fires_unconditionally() { let groups = vec![make_group(None, &["echo hi"])]; - let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); assert_eq!(actual.len(), 1); assert_eq!(actual[0].command, Some("echo hi".to_string())); } @@ -615,42 +680,42 @@ mod tests { #[test] fn test_find_matching_hooks_no_matcher_fires_without_subject() { let groups = vec![make_group(None, &["echo hi"])]; - let actual = UserHookHandler::find_matching_hooks(&groups, None); + let actual = UserHookHandler::::find_matching_hooks(&groups, None); assert_eq!(actual.len(), 1); } #[test] fn test_find_matching_hooks_regex_match() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; - let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); assert_eq!(actual.len(), 1); } #[test] fn test_find_matching_hooks_regex_no_match() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; - let actual = UserHookHandler::find_matching_hooks(&groups, Some("Write")); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Write")); assert!(actual.is_empty()); } #[test] fn test_find_matching_hooks_regex_partial_match() { let groups = vec![make_group(Some("Bash|Write"), &["check.sh"])]; - let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); assert_eq!(actual.len(), 1); } #[test] fn test_find_matching_hooks_matcher_but_no_subject() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; - let actual = UserHookHandler::find_matching_hooks(&groups, None); + let actual = UserHookHandler::::find_matching_hooks(&groups, None); assert!(actual.is_empty()); } #[test] fn test_find_matching_hooks_invalid_regex_skipped() { let groups = vec![make_group(Some("[invalid"), &["block.sh"])]; - let actual = UserHookHandler::find_matching_hooks(&groups, Some("anything")); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("anything")); assert!(actual.is_empty()); } @@ -661,7 +726,7 @@ mod tests { make_group(Some("Write"), &["write-hook.sh"]), make_group(None, &["always.sh"]), ]; - let actual = UserHookHandler::find_matching_hooks(&groups, Some("Bash")); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); assert_eq!(actual.len(), 2); // Bash match + unconditional } @@ -672,7 +737,7 @@ mod tests { stdout: String::new(), stderr: String::new(), }]; - let actual = UserHookHandler::process_pre_tool_use_output(&results); + let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Allow)); } @@ -683,7 +748,7 @@ mod tests { stdout: String::new(), stderr: "Blocked: dangerous command".to_string(), }]; - let actual = UserHookHandler::process_pre_tool_use_output(&results); + let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!( matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("dangerous command")) ); @@ -696,7 +761,7 @@ mod tests { stdout: r#"{"permissionDecision": "deny", "reason": "Not allowed"}"#.to_string(), stderr: String::new(), }]; - let actual = UserHookHandler::process_pre_tool_use_output(&results); + let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Not allowed")); } @@ -707,7 +772,7 @@ mod tests { stdout: r#"{"decision": "block", "reason": "Blocked by policy"}"#.to_string(), stderr: String::new(), }]; - let actual = UserHookHandler::process_pre_tool_use_output(&results); + let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Blocked by policy")); } @@ -718,7 +783,7 @@ mod tests { stdout: String::new(), stderr: "some error".to_string(), }]; - let actual = UserHookHandler::process_pre_tool_use_output(&results); + let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Allow)); } @@ -729,7 +794,7 @@ mod tests { stdout: String::new(), stderr: String::new(), }]; - let actual = UserHookHandler::process_results(&results); + let actual = UserHookHandler::::process_results(&results); assert!(actual.is_none()); } @@ -740,7 +805,7 @@ mod tests { stdout: String::new(), stderr: "stop reason".to_string(), }]; - let actual = UserHookHandler::process_results(&results); + let actual = UserHookHandler::::process_results(&results); assert_eq!(actual, Some("stop reason".to_string())); } @@ -751,20 +816,14 @@ mod tests { stdout: r#"{"decision": "block", "reason": "keep going"}"#.to_string(), stderr: String::new(), }]; - let actual = UserHookHandler::process_results(&results); + let actual = UserHookHandler::::process_results(&results); assert_eq!(actual, Some("keep going".to_string())); } #[test] fn test_has_hooks_returns_false_for_empty_config() { let config = UserHookConfig::new(); - let handler = UserHookHandler::new( - config, - PathBuf::from("/tmp"), - PathBuf::from("/tmp"), - "sess-1".to_string(), - 0, - ); + let handler = null_handler(config); assert!(!handler.has_hooks(&UserHookEventName::PreToolUse)); } @@ -772,13 +831,7 @@ mod tests { fn test_has_hooks_returns_true_when_configured() { let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); - let handler = UserHookHandler::new( - config, - PathBuf::from("/tmp"), - PathBuf::from("/tmp"), - "sess-1".to_string(), - 0, - ); + let handler = null_handler(config); assert!(handler.has_hooks(&UserHookEventName::PreToolUse)); assert!(!handler.has_hooks(&UserHookEventName::Stop)); } diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index aab687c1c8..7712c23e8c 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -1,6 +1,7 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::hash::Hash; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::Result; use bytes::Bytes; @@ -148,6 +149,31 @@ pub trait CommandInfra: Send + Sync { working_dir: PathBuf, env_vars: Option>, ) -> anyhow::Result; + + /// Executes a shell command with stdin input and a timeout. + /// + /// Pipes `stdin_input` to the process stdin, captures stdout and stderr, + /// and waits up to `timeout` for the process to complete. On timeout, + /// returns `CommandOutput` with `exit_code: None` and a timeout message in + /// `stderr`. + /// + /// # Arguments + /// * `command` - Shell command string to execute. + /// * `working_dir` - Working directory for the command. + /// * `stdin_input` - Data to pipe to the process stdin. + /// * `timeout` - Maximum duration to wait for the command. + /// * `env_vars` - Additional environment variables as key-value pairs. + /// + /// # Errors + /// Returns an error if the process cannot be spawned. + async fn execute_command_with_input( + &self, + command: String, + working_dir: PathBuf, + stdin_input: String, + timeout: Duration, + env_vars: HashMap, + ) -> anyhow::Result; } #[async_trait::async_trait] diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 86b82433fa..b90311269e 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -554,6 +555,37 @@ pub trait ProviderAuthService: Send + Sync { ) -> anyhow::Result>; } +/// Service for executing hook commands with stdin input and timeout. +/// +/// Abstracts over the underlying process execution so that `UserHookExecutor` +/// depends on a service rather than infrastructure directly. +#[async_trait::async_trait] +pub trait HookCommandService: Send + Sync { + /// Executes a shell command with stdin input and a timeout. + /// + /// Pipes `stdin_input` to the process stdin and waits up to `timeout`. + /// Returns `CommandOutput` with `exit_code: None` and a timeout message in + /// `stderr` when the timeout expires. + /// + /// # Arguments + /// * `command` - Shell command string to execute. + /// * `working_dir` - Working directory for the command. + /// * `stdin_input` - Data to pipe to the process stdin. + /// * `timeout` - Maximum duration to wait for the command. + /// * `env_vars` - Additional environment variables as key-value pairs. + /// + /// # Errors + /// Returns an error if the process cannot be spawned. + async fn execute_command_with_input( + &self, + command: String, + working_dir: PathBuf, + stdin_input: String, + timeout: Duration, + env_vars: HashMap, + ) -> anyhow::Result; +} + pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra { type ProviderService: ProviderService; type AppConfigService: AppConfigService; @@ -583,6 +615,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra { type ProviderAuthService: ProviderAuthService; type WorkspaceService: WorkspaceService; type SkillFetchService: SkillFetchService; + type HookCommandService: HookCommandService + Clone; fn provider_service(&self) -> &Self::ProviderService; fn config_service(&self) -> &Self::AppConfigService; @@ -612,6 +645,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra { fn provider_auth_service(&self) -> &Self::ProviderAuthService; fn workspace_service(&self) -> &Self::WorkspaceService; fn skill_fetch_service(&self) -> &Self::SkillFetchService; + fn hook_command_service(&self) -> &Self::HookCommandService; } #[async_trait::async_trait] diff --git a/crates/forge_infra/src/executor.rs b/crates/forge_infra/src/executor.rs index f4952c9046..9a1abaa43c 100644 --- a/crates/forge_infra/src/executor.rs +++ b/crates/forge_infra/src/executor.rs @@ -1,10 +1,12 @@ +use std::collections::HashMap; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Duration; use forge_app::CommandInfra; use forge_domain::{CommandOutput, ConsoleWriter as OutputPrinterTrait, Environment}; -use tokio::io::AsyncReadExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use tokio::sync::Mutex; @@ -224,6 +226,62 @@ impl CommandInfra for ForgeCommandExecutorService { Ok(prepared_command.spawn()?.wait().await?) } + + async fn execute_command_with_input( + &self, + command: String, + working_dir: PathBuf, + stdin_input: String, + timeout: Duration, + env_vars: HashMap, + ) -> anyhow::Result { + let mut prepared_command = self.prepare_command(&command, &working_dir, None); + + // Set directly-provided key-value env vars + for (key, value) in &env_vars { + prepared_command.env(key, value); + } + + // Override stdin to piped so we can write to it + prepared_command.stdin(std::process::Stdio::piped()); + + let mut child = prepared_command.spawn()?; + + // Pipe the JSON input to stdin + if let Some(mut stdin) = child.stdin.take() { + let input = stdin_input.clone(); + tokio::spawn(async move { + let _ = stdin.write_all(input.as_bytes()).await; + let _ = stdin.shutdown().await; + }); + } + + // Wait for the command with timeout + let result = tokio::time::timeout(timeout, child.wait_with_output()).await; + + match result { + Ok(Ok(output)) => Ok(CommandOutput { + command, + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }), + Ok(Err(e)) => Err(e.into()), + Err(_) => { + tracing::warn!( + command = command, + timeout_ms = timeout.as_millis() as u64, + "Hook command timed out" + ); + Ok(CommandOutput { + command, + exit_code: None, + stdout: String::new(), + stderr: format!("Hook command timed out after {}ms", timeout.as_millis()), + }) + } + } + } } #[cfg(test)] diff --git a/crates/forge_infra/src/forge_infra.rs b/crates/forge_infra/src/forge_infra.rs index b899eee341..9aa64e6500 100644 --- a/crates/forge_infra/src/forge_infra.rs +++ b/crates/forge_infra/src/forge_infra.rs @@ -1,7 +1,8 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::process::ExitStatus; use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use forge_app::{ @@ -211,6 +212,19 @@ impl CommandInfra for ForgeInfra { .execute_command_raw(command, working_dir, env_vars) .await } + + async fn execute_command_with_input( + &self, + command: String, + working_dir: PathBuf, + stdin_input: String, + timeout: Duration, + env_vars: HashMap, + ) -> anyhow::Result { + self.command_executor_service + .execute_command_with_input(command, working_dir, stdin_input, timeout, env_vars) + .await + } } #[async_trait::async_trait] diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 82df21eda0..a1db110867 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -1,6 +1,7 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use forge_app::{ @@ -453,6 +454,19 @@ where .execute_command_raw(command, working_dir, env_vars) .await } + + async fn execute_command_with_input( + &self, + command: String, + working_dir: PathBuf, + stdin_input: String, + timeout: Duration, + env_vars: HashMap, + ) -> anyhow::Result { + self.infra + .execute_command_with_input(command, working_dir, stdin_input, timeout, env_vars) + .await + } } #[async_trait::async_trait] diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 34a0b26eed..e75aeea1e2 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -26,7 +26,8 @@ use crate::provider_service::ForgeProviderService; use crate::template::ForgeTemplateService; use crate::tool_services::{ ForgeFetch, ForgeFollowup, ForgeFsPatch, ForgeFsRead, ForgeFsRemove, ForgeFsSearch, - ForgeFsUndo, ForgeFsWrite, ForgeImageRead, ForgePlanCreate, ForgeShell, ForgeSkillFetch, + ForgeFsUndo, ForgeFsWrite, ForgeHookCommandService, ForgeImageRead, ForgePlanCreate, + ForgeShell, ForgeSkillFetch, }; use crate::user_hook_config::ForgeUserHookConfigService; @@ -85,6 +86,7 @@ pub struct ForgeServices< provider_auth_service: ForgeProviderAuthService, workspace_service: Arc>>, skill_service: Arc>, + hook_command_service: Arc>, infra: Arc, } @@ -145,6 +147,7 @@ impl< discovery, )); let skill_service = Arc::new(ForgeSkillFetch::new(infra.clone())); + let hook_command_service = Arc::new(ForgeHookCommandService::new(infra.clone())); Self { conversation_service, @@ -174,6 +177,7 @@ impl< provider_auth_service, workspace_service, skill_service, + hook_command_service, chat_service, infra, } @@ -242,6 +246,7 @@ impl< type ProviderService = ForgeProviderService; type WorkspaceService = crate::context_engine::ForgeWorkspaceService>; type SkillFetchService = ForgeSkillFetch; + type HookCommandService = ForgeHookCommandService; fn config_service(&self) -> &Self::AppConfigService { &self.config_service @@ -346,6 +351,10 @@ impl< &self.skill_service } + fn hook_command_service(&self) -> &Self::HookCommandService { + &self.hook_command_service + } + fn provider_service(&self) -> &Self::ProviderService { &self.chat_service } diff --git a/crates/forge_services/src/tool_services/mod.rs b/crates/forge_services/src/tool_services/mod.rs index 64a5c6f3c0..75e78f3d7a 100644 --- a/crates/forge_services/src/tool_services/mod.rs +++ b/crates/forge_services/src/tool_services/mod.rs @@ -6,6 +6,7 @@ mod fs_remove; mod fs_search; mod fs_undo; mod fs_write; +mod hook_command; mod image_read; mod plan_create; mod shell; @@ -19,6 +20,7 @@ pub use fs_remove::*; pub use fs_search::*; pub use fs_undo::*; pub use fs_write::*; +pub use hook_command::*; pub use image_read::*; pub use plan_create::*; pub use shell::*; diff --git a/crates/forge_services/src/tool_services/shell.rs b/crates/forge_services/src/tool_services/shell.rs index cdfae686ab..ed117aef07 100644 --- a/crates/forge_services/src/tool_services/shell.rs +++ b/crates/forge_services/src/tool_services/shell.rs @@ -108,6 +108,22 @@ mod tests { ) -> anyhow::Result { unimplemented!() } + + async fn execute_command_with_input( + &self, + command: String, + _working_dir: PathBuf, + _stdin_input: String, + _timeout: std::time::Duration, + _env_vars: std::collections::HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }) + } } impl EnvironmentInfra for MockCommandInfra { From 2ff8b98c4477ae200c899088e417691a0e3ffc31 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:10:27 -0400 Subject: [PATCH 15/64] fix(info): read hook timeout from config instead of env --- crates/forge_main/src/info.rs | 2 +- crates/forge_services/src/user_hook_config.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index c9bc38af45..609c3daef5 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -387,7 +387,7 @@ impl From<&ForgeConfig> for Info { .add_key_value("ForgeCode Service URL", config.services_url.to_string()) .add_title("TOOL CONFIGURATION") .add_key_value("Tool Timeout", format!("{}s", config.tool_timeout_secs)) - .add_key_value("Hook Timeout", format!("{}ms", env.hook_timeout)) + .add_key_value("Hook Timeout", format!("{}ms", config.hook_timeout_ms.unwrap_or(0))) .add_key_value( "Max Image Size", format!("{} bytes", config.max_image_size_bytes), diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index 3ae4c6d89c..f85ec73781 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -263,6 +263,8 @@ mod tests { } impl EnvironmentInfra for TestInfra { + type Config = forge_config::ForgeConfig; + fn get_env_var(&self, _key: &str) -> Option { None } @@ -278,6 +280,10 @@ mod tests { env } + fn get_config(&self) -> forge_config::ForgeConfig { + Default::default() + } + async fn update_environment( &self, _ops: Vec, From 01a6bf648a6bf84e356cfb336ab62660898328c5 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:11:32 -0400 Subject: [PATCH 16/64] fix merge errors --- crates/forge_app/src/app.rs | 2 - .../forge_app/src/hooks/user_hook_executor.rs | 30 +- .../forge_app/src/hooks/user_hook_handler.rs | 47 +- crates/forge_domain/src/env.rs | 204 +------ crates/forge_infra/src/env.rs | 508 +++++------------- 5 files changed, 143 insertions(+), 648 deletions(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index f5c9f7a36b..162729c2e7 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -167,9 +167,7 @@ impl ForgeApp { services.hook_command_service().clone(), user_hook_config, environment.cwd.clone(), - environment.cwd.clone(), conversation.id.to_string(), - environment.hook_timeout, ); let user_hook = Hook::default() .on_start(user_handler.clone()) diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index 5d691847b9..40047d90aa 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -7,9 +7,6 @@ use tracing::debug; use crate::services::HookCommandService; -/// Default timeout for hook commands (10 minutes). -const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(600); - /// Executes user hook commands by delegating to a [`HookCommandService`]. /// /// Holds the service by value; the service itself is responsible for any @@ -32,9 +29,7 @@ impl UserHookExecutor { /// # Arguments /// * `command` - The shell command string to execute. /// * `input_json` - JSON string to pipe to the command's stdin. - /// * `timeout` - Optional per-hook timeout in milliseconds. Falls back to - /// `default_timeout_ms` when `None`. - /// * `default_timeout_ms` - Default timeout in milliseconds from the + /// * `timeout` - per-hook timeout in milliseconds. Falls back to /// environment configuration. Uses the built-in default (10 min) when /// zero. /// * `cwd` - Working directory for the command. @@ -46,19 +41,10 @@ impl UserHookExecutor { &self, command: &str, input_json: &str, - timeout: Option, - default_timeout_ms: u64, + timeout_duration: Duration, cwd: &PathBuf, env_vars: &HashMap, ) -> anyhow::Result { - let timeout_duration = timeout.map(Duration::from_millis).unwrap_or_else(|| { - if default_timeout_ms > 0 { - Duration::from_millis(default_timeout_ms) - } else { - DEFAULT_HOOK_TIMEOUT - } - }); - debug!( command = command, cwd = %cwd.display(), @@ -168,8 +154,7 @@ mod tests { .execute( "echo hello", "{}", - None, - 0, + Duration::from_secs(0), &std::env::current_dir().unwrap(), &HashMap::new(), ) @@ -188,8 +173,7 @@ mod tests { .execute( "exit 2", "{}", - None, - 0, + Duration::from_secs(0), &std::env::current_dir().unwrap(), &HashMap::new(), ) @@ -208,8 +192,7 @@ mod tests { .execute( "exit 1", "{}", - None, - 0, + Duration::from_secs(0), &std::env::current_dir().unwrap(), &HashMap::new(), ) @@ -227,8 +210,7 @@ mod tests { .execute( "sleep 10", "{}", - Some(100), - 0, + Duration::from_millis(100), &std::env::current_dir().unwrap(), &HashMap::new(), ) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 486f4d2295..92ff6a8c0c 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -1,7 +1,3 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; - use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, @@ -10,41 +6,34 @@ use forge_domain::{ UserHookEventName, UserHookMatcherGroup, }; use regex::Regex; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use tracing::{debug, warn}; use super::user_hook_executor::UserHookExecutor; use crate::services::HookCommandService; +/// Default timeout for hook commands (10 minutes). +const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(600); + /// EventHandle implementation that bridges user-configured hooks with the /// existing lifecycle event system. /// /// This handler is constructed from a `UserHookConfig` and executes matching /// hook commands at each lifecycle event point. It wires into the existing /// `Hook` system via `Hook::zip()`. +#[derive(Clone)] pub struct UserHookHandler { executor: UserHookExecutor, config: UserHookConfig, cwd: PathBuf, env_vars: HashMap, - /// Default timeout in milliseconds for hook commands from the environment. - default_hook_timeout: u64, /// Tracks whether a Stop hook has already fired to prevent infinite loops. stop_hook_active: std::sync::Arc, } -impl Clone for UserHookHandler { - fn clone(&self) -> Self { - Self { - executor: self.executor.clone(), - config: self.config.clone(), - cwd: self.cwd.clone(), - env_vars: self.env_vars.clone(), - default_hook_timeout: self.default_hook_timeout, - stop_hook_active: self.stop_hook_active.clone(), - } - } -} - impl UserHookHandler { /// Creates a new user hook handler from configuration. /// @@ -61,14 +50,12 @@ impl UserHookHandler { service: I, config: UserHookConfig, cwd: PathBuf, - project_dir: PathBuf, session_id: String, - default_hook_timeout: u64, ) -> Self { let mut env_vars = HashMap::new(); env_vars.insert( "FORGE_PROJECT_DIR".to_string(), - project_dir.to_string_lossy().to_string(), + cwd.to_string_lossy().to_string(), ); env_vars.insert("FORGE_SESSION_ID".to_string(), session_id); env_vars.insert("FORGE_CWD".to_string(), cwd.to_string_lossy().to_string()); @@ -78,7 +65,6 @@ impl UserHookHandler { config, cwd, env_vars, - default_hook_timeout, stop_hook_active: std::sync::Arc::new(AtomicBool::new(false)), } } @@ -152,8 +138,9 @@ impl UserHookHandler { .execute( command, &input_json, - hook.timeout, - self.default_hook_timeout, + hook.timeout + .map(Duration::from_millis) + .unwrap_or(DEFAULT_HOOK_TIMEOUT), &self.cwd, &self.env_vars, ) @@ -395,9 +382,7 @@ impl EventHandle> for UserHook } #[async_trait] -impl EventHandle> - for UserHookHandler -{ +impl EventHandle> for UserHookHandler { async fn handle( &self, event: &EventData, @@ -462,9 +447,7 @@ impl EventHandle> } #[async_trait] -impl EventHandle> - for UserHookHandler -{ +impl EventHandle> for UserHookHandler { async fn handle( &self, event: &EventData, @@ -648,9 +631,7 @@ mod tests { NullInfra, config, PathBuf::from("/tmp"), - PathBuf::from("/tmp"), "sess-1".to_string(), - 0, ) } diff --git a/crates/forge_domain/src/env.rs b/crates/forge_domain/src/env.rs index 23e3f0b0b5..a614ce0e27 100644 --- a/crates/forge_domain/src/env.rs +++ b/crates/forge_domain/src/env.rs @@ -1,16 +1,11 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::PathBuf; -use std::str::FromStr; use derive_more::Display; use derive_setters::Setters; use serde::{Deserialize, Serialize}; -use url::Url; -use crate::{ - Compact, HttpConfig, MaxTokens, ModelId, ProviderId, RetryConfig, - Temperature, TopK, TopP, Update, -}; +use crate::{ModelId, ProviderId}; /// Domain-level session configuration pairing a provider with a model. /// @@ -70,206 +65,9 @@ pub struct Environment { pub shell: String, /// The base path relative to which everything else is stored. pub base_path: PathBuf, - /// Configuration for the retry mechanism - pub retry_config: RetryConfig, - /// The maximum number of lines returned for FSSearch. - pub max_search_lines: usize, - /// Maximum bytes allowed for search results - pub max_search_result_bytes: usize, - /// Maximum characters for fetch content - pub fetch_truncation_limit: usize, - /// Maximum lines for shell output prefix - pub stdout_max_prefix_length: usize, - /// Maximum lines for shell output suffix - pub stdout_max_suffix_length: usize, - /// Maximum characters per line for shell output - pub stdout_max_line_length: usize, - /// Maximum characters per line for file read operations - /// Controlled by FORGE_MAX_LINE_LENGTH environment variable. - pub max_line_length: usize, - /// Maximum number of lines to read from a file - pub max_read_size: u64, - /// Maximum number of files that can be read in a single batch operation. - /// Controlled by FORGE_MAX_READ_BATCH_SIZE environment variable. - pub max_file_read_batch_size: usize, - /// Http configuration - pub http: HttpConfig, - /// Maximum file size in bytes for operations - pub max_file_size: u64, - /// Maximum image file size in bytes for binary read operations - pub max_image_size: u64, - /// Maximum execution time in seconds for a single tool call. - /// Controls how long a tool can run before being terminated. - pub tool_timeout: u64, - /// Default timeout in milliseconds for user hook commands. - /// Individual hooks can override this via their own `timeout` field. - /// Controlled by FORGE_HOOK_TIMEOUT_MS environment variable. - pub hook_timeout: u64, - /// Whether to automatically open HTML dump files in the browser. - /// Controlled by FORGE_DUMP_AUTO_OPEN environment variable. - pub auto_open_dump: bool, - /// Path where debug request files should be written. - /// Controlled by FORGE_DEBUG_REQUESTS environment variable. - pub debug_requests: Option, - /// Custom history file path from FORGE_HISTORY_FILE environment variable. - /// If None, uses the default history path. - pub custom_history_path: Option, - /// Maximum number of conversations to show in list. - /// Controlled by FORGE_MAX_CONVERSATIONS environment variable. - pub max_conversations: usize, - /// Maximum number of results to return from initial vector search. - /// Controlled by FORGE_SEM_SEARCH_LIMIT environment variable. - pub sem_search_limit: usize, - /// Top-k parameter for relevance filtering during semantic search. - /// Controls the number of nearest neighbors to consider. - /// Controlled by FORGE_SEM_SEARCH_TOP_K environment variable. - pub sem_search_top_k: usize, - /// URL for the indexing server. - /// Controlled by FORGE_WORKSPACE_SERVER_URL environment variable. - #[dummy(expr = "url::Url::parse(\"http://localhost:8080\").unwrap()")] - pub service_url: Url, - /// Maximum number of file extensions to include in the system prompt. - /// Controlled by FORGE_MAX_EXTENSIONS environment variable. - pub max_extensions: usize, - /// Format for automatically creating a dump when a task is completed. - /// Controlled by FORGE_AUTO_DUMP environment variable. - /// Set to "json" (or "true"/"1"/"yes") for JSON, "html" for HTML, or - /// unset/other to disable. - pub auto_dump: Option, - /// Maximum number of files read concurrently in parallel operations. - /// Controlled by FORGE_PARALLEL_FILE_READS environment variable. - /// Caps the `buffer_unordered` concurrency to avoid EMFILE errors. - pub parallel_file_reads: usize, - /// TTL in seconds for the model API list cache. - /// Controlled by FORGE_MODEL_CACHE_TTL environment variable. - pub model_cache_ttl: u64, - - // --- User configuration fields (from ForgeConfig) --- - /// The active session (provider + model). - #[dummy(default)] - pub session: Option, - /// Provider and model for commit message generation. - #[dummy(default)] - pub commit: Option, - /// Provider and model for shell command suggestion generation. - #[dummy(default)] - pub suggest: Option, - /// Whether the application is running in restricted mode. - /// When true, tool execution requires explicit permission grants. - pub is_restricted: bool, - - /// Whether tool use is supported in the current environment. - /// When false, tool calls are disabled regardless of agent configuration. - pub tool_supported: bool, - - // --- Workflow configuration fields --- - /// Output randomness for all agents; lower values are deterministic, higher - /// values are creative (0.0–2.0). - #[dummy(default)] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub temperature: Option, - - /// Nucleus sampling threshold for all agents; limits token selection to the - /// top cumulative probability mass (0.0–1.0). - #[dummy(default)] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub top_p: Option, - - /// Top-k vocabulary cutoff for all agents; restricts sampling to the k - /// highest-probability tokens (1–1000). - #[dummy(default)] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub top_k: Option, - - /// Maximum tokens the model may generate per response for all agents - /// (1–100,000). - #[dummy(default)] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_tokens: Option, - - /// Maximum tool failures per turn before the orchestrator forces - /// completion. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_tool_failure_per_turn: Option, - - /// Maximum number of requests that can be made in a single turn. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_requests_per_turn: Option, - - /// Context compaction settings applied to all agents. - #[dummy(default)] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub compact: Option, - - /// Configuration for automatic forge updates. - #[dummy(default)] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub updates: Option, -} - -/// The output format used when auto-dumping a conversation on task completion. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] -#[serde(rename_all = "camelCase")] -pub enum AutoDumpFormat { - /// Dump as a JSON file. - Json, - /// Dump as an HTML file. - Html, -} - -impl FromStr for AutoDumpFormat { - type Err = (); - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "html" => Ok(AutoDumpFormat::Html), - "json" | "true" | "1" | "yes" => Ok(AutoDumpFormat::Json), - _ => Err(()), - } - } } impl Environment { - /// Applies a single [`ConfigOperation`] to this environment in-place. - pub fn apply_op(&mut self, op: ConfigOperation) { - match op { - ConfigOperation::SetProvider(provider_id) => { - let pid = provider_id.as_ref().to_string(); - self.session = Some(match self.session.take() { - Some(sc) => sc.provider_id(pid), - None => SessionConfig::default().provider_id(pid), - }); - } - ConfigOperation::SetModel(provider_id, model_id) => { - let pid = provider_id.as_ref().to_string(); - let mid = model_id.to_string(); - self.session = Some(match self.session.take() { - Some(sc) if sc.provider_id.as_deref() == Some(&pid) => sc.model_id(mid), - _ => SessionConfig::default().provider_id(pid).model_id(mid), - }); - } - ConfigOperation::SetCommitConfig(commit) => { - self.commit = - commit - .provider - .as_ref() - .zip(commit.model.as_ref()) - .map(|(pid, mid)| { - SessionConfig::default() - .provider_id(pid.as_ref().to_string()) - .model_id(mid.to_string()) - }); - } - ConfigOperation::SetSuggestConfig(suggest) => { - self.suggest = Some( - SessionConfig::default() - .provider_id(suggest.provider.as_ref().to_string()) - .model_id(suggest.model.to_string()), - ); - } - } - } - pub fn log_path(&self) -> PathBuf { self.base_path.join("logs") } diff --git a/crates/forge_infra/src/env.rs b/crates/forge_infra/src/env.rs index 10bce7b7b6..c2b2d91f15 100644 --- a/crates/forge_infra/src/env.rs +++ b/crates/forge_infra/src/env.rs @@ -4,122 +4,17 @@ use std::sync::Arc; use forge_app::EnvironmentInfra; use forge_config::{ConfigReader, ForgeConfig, ModelConfig}; -use forge_domain::{ - AutoDumpFormat, Compact, ConfigOperation, Environment, HttpConfig, MaxTokens, ModelId, - RetryConfig, SessionConfig, Temperature, TlsBackend, TlsVersion, TopK, TopP, Update, - UpdateFrequency, -}; -use reqwest::Url; +use forge_domain::{ConfigOperation, Environment}; use tracing::{debug, error}; -/// Converts a [`ModelConfig`] into a domain-level [`SessionConfig`]. -fn to_session_config(mc: &ModelConfig) -> SessionConfig { - SessionConfig { - provider_id: mc.provider_id.clone(), - model_id: mc.model_id.clone(), - } -} - -/// Converts a [`forge_config::TlsVersion`] into a [`forge_domain::TlsVersion`]. -fn to_tls_version(v: forge_config::TlsVersion) -> TlsVersion { - match v { - forge_config::TlsVersion::V1_0 => TlsVersion::V1_0, - forge_config::TlsVersion::V1_1 => TlsVersion::V1_1, - forge_config::TlsVersion::V1_2 => TlsVersion::V1_2, - forge_config::TlsVersion::V1_3 => TlsVersion::V1_3, - } -} - -/// Converts a [`forge_config::TlsBackend`] into a [`forge_domain::TlsBackend`]. -fn to_tls_backend(b: forge_config::TlsBackend) -> TlsBackend { - match b { - forge_config::TlsBackend::Default => TlsBackend::Default, - forge_config::TlsBackend::Rustls => TlsBackend::Rustls, - } -} - -/// Converts a [`forge_config::HttpConfig`] into a [`forge_domain::HttpConfig`]. -fn to_http_config(h: forge_config::HttpConfig) -> HttpConfig { - HttpConfig { - connect_timeout: h.connect_timeout_secs, - read_timeout: h.read_timeout_secs, - pool_idle_timeout: h.pool_idle_timeout_secs, - pool_max_idle_per_host: h.pool_max_idle_per_host, - max_redirects: h.max_redirects, - hickory: h.hickory, - tls_backend: to_tls_backend(h.tls_backend), - min_tls_version: h.min_tls_version.map(to_tls_version), - max_tls_version: h.max_tls_version.map(to_tls_version), - adaptive_window: h.adaptive_window, - keep_alive_interval: h.keep_alive_interval_secs, - keep_alive_timeout: h.keep_alive_timeout_secs, - keep_alive_while_idle: h.keep_alive_while_idle, - accept_invalid_certs: h.accept_invalid_certs, - root_cert_paths: h.root_cert_paths, - } -} - -/// Converts a [`forge_config::RetryConfig`] into a -/// [`forge_domain::RetryConfig`]. -fn to_retry_config(r: forge_config::RetryConfig) -> RetryConfig { - RetryConfig { - initial_backoff_ms: r.initial_backoff_ms, - min_delay_ms: r.min_delay_ms, - backoff_factor: r.backoff_factor, - max_retry_attempts: r.max_attempts, - retry_status_codes: r.status_codes, - max_delay: r.max_delay_secs, - suppress_retry_errors: r.suppress_errors, - } -} - -/// Converts a [`forge_config::AutoDumpFormat`] into a -/// [`forge_domain::AutoDumpFormat`]. -fn to_auto_dump_format(f: forge_config::AutoDumpFormat) -> AutoDumpFormat { - match f { - forge_config::AutoDumpFormat::Json => AutoDumpFormat::Json, - forge_config::AutoDumpFormat::Html => AutoDumpFormat::Html, - } -} - -/// Converts a [`forge_config::UpdateFrequency`] into a -/// [`forge_domain::UpdateFrequency`]. -fn to_update_frequency(f: forge_config::UpdateFrequency) -> UpdateFrequency { - match f { - forge_config::UpdateFrequency::Daily => UpdateFrequency::Daily, - forge_config::UpdateFrequency::Weekly => UpdateFrequency::Weekly, - forge_config::UpdateFrequency::Always => UpdateFrequency::Always, - } -} - -/// Converts a [`forge_config::Update`] into a [`forge_domain::Update`]. -fn to_update(u: forge_config::Update) -> Update { - Update { - frequency: u.frequency.map(to_update_frequency), - auto_update: u.auto_update, - } -} - -/// Converts a [`forge_config::Compact`] into a [`forge_domain::Compact`]. -fn to_compact(c: forge_config::Compact) -> Compact { - Compact { - retention_window: c.retention_window, - eviction_window: c.eviction_window.value(), - max_tokens: c.max_tokens, - token_threshold: c.token_threshold, - turn_threshold: c.turn_threshold, - message_threshold: c.message_threshold, - model: c.model.map(ModelId::new), - on_turn_end: c.on_turn_end, - } -} - -/// Builds a [`forge_domain::Environment`] entirely from a [`ForgeConfig`] and -/// runtime context (`cwd`), mapping every config field to its corresponding -/// environment field. -fn to_environment(fc: ForgeConfig, cwd: PathBuf) -> Environment { +/// Builds a [`forge_domain::Environment`] from runtime context only. +/// +/// Only the six fields that cannot be sourced from [`ForgeConfig`] are set +/// here: `os`, `pid`, `cwd`, `home`, `shell`, and `base_path`. All +/// configuration values are now accessed through +/// `EnvironmentInfra::get_config()`. +fn to_environment(cwd: PathBuf) -> Environment { Environment { - // --- Infrastructure-derived fields --- os: std::env::consts::OS.to_string(), pid: std::process::id(), cwd, @@ -132,225 +27,47 @@ fn to_environment(fc: ForgeConfig, cwd: PathBuf) -> Environment { base_path: dirs::home_dir() .map(|h| h.join("forge")) .unwrap_or_else(|| PathBuf::from(".").join("forge")), - - // --- ForgeConfig-mapped fields --- - retry_config: fc.retry.map(to_retry_config).unwrap_or_default(), - max_search_lines: fc.max_search_lines, - max_search_result_bytes: fc.max_search_result_bytes, - fetch_truncation_limit: fc.max_fetch_chars, - stdout_max_prefix_length: fc.max_stdout_prefix_lines, - stdout_max_suffix_length: fc.max_stdout_suffix_lines, - stdout_max_line_length: fc.max_stdout_line_chars, - max_line_length: fc.max_line_chars, - max_read_size: fc.max_read_lines, - max_file_read_batch_size: fc.max_file_read_batch_size, - http: fc.http.map(to_http_config).unwrap_or_default(), - max_file_size: fc.max_file_size_bytes, - max_image_size: fc.max_image_size_bytes, - tool_timeout: fc.tool_timeout_secs, - hook_timeout: fc.hook_timeout_ms.unwrap_or_default(), - auto_open_dump: fc.auto_open_dump, - debug_requests: fc.debug_requests, - custom_history_path: fc.custom_history_path, - max_conversations: fc.max_conversations, - sem_search_limit: fc.max_sem_search_results, - sem_search_top_k: fc.sem_search_top_k, - service_url: Url::parse(&fc.services_url) - .unwrap_or_else(|_| Url::parse("https://api.forgecode.dev").unwrap()), - max_extensions: fc.max_extensions, - auto_dump: fc.auto_dump.map(to_auto_dump_format), - parallel_file_reads: fc.max_parallel_file_reads, - model_cache_ttl: fc.model_cache_ttl_secs, - session: fc.session.as_ref().map(to_session_config), - commit: fc.commit.as_ref().map(to_session_config), - suggest: fc.suggest.as_ref().map(to_session_config), - is_restricted: fc.restricted, - tool_supported: fc.tool_supported, - temperature: fc - .temperature - .and_then(|v| Temperature::new(v.value() as f32).ok()), - top_p: fc.top_p.and_then(|v| TopP::new(v.value() as f32).ok()), - top_k: fc.top_k.and_then(|v| TopK::new(v).ok()), - max_tokens: fc.max_tokens.and_then(|v| MaxTokens::new(v).ok()), - max_tool_failure_per_turn: fc.max_tool_failure_per_turn, - max_requests_per_turn: fc.max_requests_per_turn, - compact: fc.compact.map(to_compact), - updates: fc.updates.map(to_update), - } -} - -/// Converts a [`forge_domain::RetryConfig`] back into a -/// [`forge_config::RetryConfig`]. -fn from_retry_config(r: &RetryConfig) -> forge_config::RetryConfig { - forge_config::RetryConfig { - initial_backoff_ms: r.initial_backoff_ms, - min_delay_ms: r.min_delay_ms, - backoff_factor: r.backoff_factor, - max_attempts: r.max_retry_attempts, - status_codes: r.retry_status_codes.clone(), - max_delay_secs: r.max_delay, - suppress_errors: r.suppress_retry_errors, - } -} - -/// Converts a [`forge_domain::HttpConfig`] back into a -/// [`forge_config::HttpConfig`]. -fn from_http_config(h: &HttpConfig) -> forge_config::HttpConfig { - forge_config::HttpConfig { - connect_timeout_secs: h.connect_timeout, - read_timeout_secs: h.read_timeout, - pool_idle_timeout_secs: h.pool_idle_timeout, - pool_max_idle_per_host: h.pool_max_idle_per_host, - max_redirects: h.max_redirects, - hickory: h.hickory, - tls_backend: from_tls_backend(h.tls_backend.clone()), - min_tls_version: h.min_tls_version.clone().map(from_tls_version), - max_tls_version: h.max_tls_version.clone().map(from_tls_version), - adaptive_window: h.adaptive_window, - keep_alive_interval_secs: h.keep_alive_interval, - keep_alive_timeout_secs: h.keep_alive_timeout, - keep_alive_while_idle: h.keep_alive_while_idle, - accept_invalid_certs: h.accept_invalid_certs, - root_cert_paths: h.root_cert_paths.clone(), } } -/// Converts a [`forge_domain::TlsVersion`] back into a -/// [`forge_config::TlsVersion`]. -fn from_tls_version(v: TlsVersion) -> forge_config::TlsVersion { - match v { - TlsVersion::V1_0 => forge_config::TlsVersion::V1_0, - TlsVersion::V1_1 => forge_config::TlsVersion::V1_1, - TlsVersion::V1_2 => forge_config::TlsVersion::V1_2, - TlsVersion::V1_3 => forge_config::TlsVersion::V1_3, - } -} - -/// Converts a [`forge_domain::TlsBackend`] back into a -/// [`forge_config::TlsBackend`]. -fn from_tls_backend(b: TlsBackend) -> forge_config::TlsBackend { - match b { - TlsBackend::Default => forge_config::TlsBackend::Default, - TlsBackend::Rustls => forge_config::TlsBackend::Rustls, - } -} - -/// Converts a [`forge_domain::AutoDumpFormat`] back into a -/// [`forge_config::AutoDumpFormat`]. -fn from_auto_dump_format(f: &AutoDumpFormat) -> forge_config::AutoDumpFormat { - match f { - AutoDumpFormat::Json => forge_config::AutoDumpFormat::Json, - AutoDumpFormat::Html => forge_config::AutoDumpFormat::Html, - } -} - -/// Converts a [`forge_domain::UpdateFrequency`] back into a -/// [`forge_config::UpdateFrequency`]. -fn from_update_frequency(f: UpdateFrequency) -> forge_config::UpdateFrequency { - match f { - UpdateFrequency::Daily => forge_config::UpdateFrequency::Daily, - UpdateFrequency::Weekly => forge_config::UpdateFrequency::Weekly, - UpdateFrequency::Always => forge_config::UpdateFrequency::Always, - } -} - -/// Converts a [`forge_domain::Update`] back into a [`forge_config::Update`]. -fn from_update(u: &Update) -> forge_config::Update { - forge_config::Update { - frequency: u.frequency.clone().map(from_update_frequency), - auto_update: u.auto_update, - } -} - -/// Converts a [`forge_domain::Compact`] back into a [`forge_config::Compact`]. -fn from_compact(c: &Compact) -> forge_config::Compact { - forge_config::Compact { - retention_window: c.retention_window, - eviction_window: forge_config::Percentage::from(c.eviction_window), - max_tokens: c.max_tokens, - token_threshold: c.token_threshold, - turn_threshold: c.turn_threshold, - message_threshold: c.message_threshold, - model: c.model.as_ref().map(|m| m.to_string()), - on_turn_end: c.on_turn_end, - } -} - -/// Converts an [`Environment`] back into a [`ForgeConfig`] suitable for -/// persisting. +/// Applies a single [`ConfigOperation`] directly to a [`ForgeConfig`]. /// -/// Builds a fresh [`ForgeConfig`] from [`ForgeConfig::default()`] and maps -/// every field that originated from [`ForgeConfig`] back from the -/// [`Environment`], preserving the round-trip identity. -fn to_forge_config(env: &Environment) -> ForgeConfig { - let mut fc = ForgeConfig::default(); - - // --- Fields mapped through Environment --- - let default_retry = RetryConfig::default(); - fc.retry = if env.retry_config == default_retry { - None - } else { - Some(from_retry_config(&env.retry_config)) - }; - fc.max_search_lines = env.max_search_lines; - fc.max_search_result_bytes = env.max_search_result_bytes; - fc.max_fetch_chars = env.fetch_truncation_limit; - fc.max_stdout_prefix_lines = env.stdout_max_prefix_length; - fc.max_stdout_suffix_lines = env.stdout_max_suffix_length; - fc.max_stdout_line_chars = env.stdout_max_line_length; - fc.max_line_chars = env.max_line_length; - fc.max_read_lines = env.max_read_size; - fc.max_file_read_batch_size = env.max_file_read_batch_size; - let default_http = HttpConfig::default(); - fc.http = if env.http == default_http { - None - } else { - Some(from_http_config(&env.http)) - }; - fc.max_file_size_bytes = env.max_file_size; - fc.max_image_size_bytes = env.max_image_size; - fc.tool_timeout_secs = env.tool_timeout; - fc.hook_timeout_ms = Some(env.hook_timeout); - fc.auto_open_dump = env.auto_open_dump; - fc.debug_requests = env.debug_requests.clone(); - fc.custom_history_path = env.custom_history_path.clone(); - fc.max_conversations = env.max_conversations; - fc.max_sem_search_results = env.sem_search_limit; - fc.sem_search_top_k = env.sem_search_top_k; - fc.services_url = env.service_url.to_string(); - fc.max_extensions = env.max_extensions; - fc.auto_dump = env.auto_dump.as_ref().map(from_auto_dump_format); - fc.max_parallel_file_reads = env.parallel_file_reads; - fc.model_cache_ttl_secs = env.model_cache_ttl; - fc.restricted = env.is_restricted; - fc.tool_supported = env.tool_supported; - - // --- Workflow fields --- - fc.temperature = env - .temperature - .map(|t| forge_config::Decimal(t.value() as f64)); - fc.top_p = env.top_p.map(|t| forge_config::Decimal(t.value() as f64)); - fc.top_k = env.top_k.map(|t| t.value()); - fc.max_tokens = env.max_tokens.map(|t| t.value()); - fc.max_tool_failure_per_turn = env.max_tool_failure_per_turn; - fc.max_requests_per_turn = env.max_requests_per_turn; - fc.compact = env.compact.as_ref().map(from_compact); - fc.updates = env.updates.as_ref().map(from_update); - - // --- Session configs --- - fc.session = env.session.as_ref().map(|sc| ModelConfig { - provider_id: sc.provider_id.clone(), - model_id: sc.model_id.clone(), - }); - fc.commit = env.commit.as_ref().map(|sc| ModelConfig { - provider_id: sc.provider_id.clone(), - model_id: sc.model_id.clone(), - }); - fc.suggest = env.suggest.as_ref().map(|sc| ModelConfig { - provider_id: sc.provider_id.clone(), - model_id: sc.model_id.clone(), - }); - fc +/// Used by [`ForgeEnvironmentInfra::update_environment`] to mutate the +/// persisted config without an intermediate `Environment` round-trip. +fn apply_config_op(fc: &mut ForgeConfig, op: ConfigOperation) { + match op { + ConfigOperation::SetProvider(pid) => { + let session = fc.session.get_or_insert_with(ModelConfig::default); + session.provider_id = Some(pid.as_ref().to_string()); + } + ConfigOperation::SetModel(pid, mid) => { + let pid_str = pid.as_ref().to_string(); + let mid_str = mid.to_string(); + let session = fc.session.get_or_insert_with(ModelConfig::default); + if session.provider_id.as_deref() == Some(&pid_str) { + session.model_id = Some(mid_str); + } else { + fc.session = + Some(ModelConfig { provider_id: Some(pid_str), model_id: Some(mid_str) }); + } + } + ConfigOperation::SetCommitConfig(commit) => { + fc.commit = commit + .provider + .as_ref() + .zip(commit.model.as_ref()) + .map(|(pid, mid)| ModelConfig { + provider_id: Some(pid.as_ref().to_string()), + model_id: Some(mid.to_string()), + }); + } + ConfigOperation::SetSuggestConfig(suggest) => { + fc.suggest = Some(ModelConfig { + provider_id: Some(suggest.provider.as_ref().to_string()), + model_id: Some(suggest.model.to_string()), + }); + } + } } /// Infrastructure implementation for managing application configuration with @@ -414,7 +131,7 @@ impl EnvironmentInfra for ForgeEnvironmentInfra { } fn get_environment(&self) -> Environment { - to_environment(self.cached_config(), self.cwd.clone()) + to_environment(self.cwd.clone()) } fn get_config(&self) -> ForgeConfig { @@ -422,23 +139,18 @@ impl EnvironmentInfra for ForgeEnvironmentInfra { } async fn update_environment(&self, ops: Vec) -> anyhow::Result<()> { - // Load the global config - let fc = ConfigReader::default() + // Load the global config (with defaults applied) for the update round-trip + let mut fc = ConfigReader::default() .read_defaults() .read_global() .build()?; - debug!(config = ?fc, "loaded config for update"); + debug!(config = ?fc, ?ops, "applying app config operations"); - // Convert to Environment and apply each operation - debug!(?ops, "applying app config operations"); - let mut env = to_environment(fc, self.cwd.clone()); for op in ops { - env.apply_op(op); + apply_config_op(&mut fc, op); } - // Convert Environment back to ForgeConfig and persist - let fc = to_forge_config(&env); fc.write()?; debug!(config = ?fc, "written .forge.toml"); @@ -459,60 +171,84 @@ mod tests { use super::*; #[test] - fn test_to_environment_default_config() { - let fixture = ForgeConfig::default(); - let actual = to_environment(fixture, PathBuf::from("/test/cwd")); - - // Config-derived fields should all be zero/default since ForgeConfig - // derives Default (all-zeros) without the defaults file. - assert_eq!(actual.cwd, PathBuf::from("/test/cwd")); - assert!(!actual.is_restricted); - assert_eq!(actual.retry_config, RetryConfig::default()); - assert_eq!(actual.http, HttpConfig::default()); - assert!(!actual.auto_open_dump); - assert_eq!(actual.auto_dump, None); - assert_eq!(actual.debug_requests, None); - assert_eq!(actual.custom_history_path, None); - assert_eq!(actual.session, None); - assert_eq!(actual.commit, None); - assert_eq!(actual.suggest, None); + fn test_to_environment_sets_cwd() { + let fixture_cwd = PathBuf::from("/test/cwd"); + let actual = to_environment(fixture_cwd.clone()); + assert_eq!(actual.cwd, fixture_cwd); } #[test] - fn test_to_environment_restricted_mode() { - let fixture = ForgeConfig::default().restricted(true); - let actual = to_environment(fixture, PathBuf::from("/tmp")); + fn test_apply_config_op_set_provider() { + use forge_domain::ProviderId; + + let mut fixture = ForgeConfig::default(); + apply_config_op( + &mut fixture, + ConfigOperation::SetProvider(ProviderId::ANTHROPIC), + ); + + let actual = fixture + .session + .as_ref() + .and_then(|s| s.provider_id.as_deref()); + let expected = Some("anthropic"); - assert!(actual.is_restricted); + assert_eq!(actual, expected); } #[test] - fn test_forge_config_environment_identity() { - // Property test: for ANY randomly generated ForgeConfig `fc`, the - // config-mapped fields of the Environment produced by - // `to_environment(fc)` must survive a full round-trip through - // `to_forge_config` and back unchanged. - // - // fc --> env --> fc' --> env' - // ^ ^ - // |--- config fields --| must be equal - use fake::{Fake, Faker}; - - let cwd = PathBuf::from("/identity/test"); - - for _ in 0..100 { - let fixture: ForgeConfig = Faker.fake(); - - // fc -> env -> fc' -> env' - let env = to_environment(fixture, cwd.clone()); - let fc_prime = to_forge_config(&env); - let env_prime = to_environment(fc_prime, cwd.clone()); - - // Infrastructure-derived fields (os, pid, home, shell, base_path) - // are re-derived from the runtime, so they are equal by - // construction. Config-mapped fields must satisfy the identity: - // env == env' - assert_eq!(env, env_prime); - } + fn test_apply_config_op_set_model_matching_provider() { + use forge_domain::{ModelId, ProviderId}; + + let mut fixture = ForgeConfig { + session: Some(ModelConfig { + provider_id: Some("anthropic".to_string()), + model_id: None, + }), + ..Default::default() + }; + + apply_config_op( + &mut fixture, + ConfigOperation::SetModel( + ProviderId::ANTHROPIC, + ModelId::new("claude-3-5-sonnet-20241022"), + ), + ); + + let actual = fixture.session.as_ref().and_then(|s| s.model_id.as_deref()); + let expected = Some("claude-3-5-sonnet-20241022"); + + assert_eq!(actual, expected); + } + + #[test] + fn test_apply_config_op_set_model_different_provider_replaces_session() { + use forge_domain::{ModelId, ProviderId}; + + let mut fixture = ForgeConfig { + session: Some(ModelConfig { + provider_id: Some("openai".to_string()), + model_id: Some("gpt-4".to_string()), + }), + ..Default::default() + }; + + apply_config_op( + &mut fixture, + ConfigOperation::SetModel( + ProviderId::ANTHROPIC, + ModelId::new("claude-3-5-sonnet-20241022"), + ), + ); + + let actual_provider = fixture + .session + .as_ref() + .and_then(|s| s.provider_id.as_deref()); + let actual_model = fixture.session.as_ref().and_then(|s| s.model_id.as_deref()); + + assert_eq!(actual_provider, Some("anthropic")); + assert_eq!(actual_model, Some("claude-3-5-sonnet-20241022")); } } From 8120fc0ca49668ee2e3b8a402220c18e44f446ea Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:14:56 +0000 Subject: [PATCH 17/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/user_hook_handler.rs | 16 ++++++---------- crates/forge_main/src/info.rs | 5 ++++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 92ff6a8c0c..1b91462cd4 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -1,3 +1,8 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, @@ -6,10 +11,6 @@ use forge_domain::{ UserHookEventName, UserHookMatcherGroup, }; use regex::Regex; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; use tracing::{debug, warn}; use super::user_hook_executor::UserHookExecutor; @@ -46,12 +47,7 @@ impl UserHookHandler { /// * `session_id` - Current session/conversation ID. /// * `default_hook_timeout` - Default timeout in milliseconds for hook /// commands. - pub fn new( - service: I, - config: UserHookConfig, - cwd: PathBuf, - session_id: String, - ) -> Self { + pub fn new(service: I, config: UserHookConfig, cwd: PathBuf, session_id: String) -> Self { let mut env_vars = HashMap::new(); env_vars.insert( "FORGE_PROJECT_DIR".to_string(), diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index 609c3daef5..45779fbced 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -387,7 +387,10 @@ impl From<&ForgeConfig> for Info { .add_key_value("ForgeCode Service URL", config.services_url.to_string()) .add_title("TOOL CONFIGURATION") .add_key_value("Tool Timeout", format!("{}s", config.tool_timeout_secs)) - .add_key_value("Hook Timeout", format!("{}ms", config.hook_timeout_ms.unwrap_or(0))) + .add_key_value( + "Hook Timeout", + format!("{}ms", config.hook_timeout_ms.unwrap_or(0)), + ) .add_key_value( "Max Image Size", format!("{} bytes", config.max_image_size_bytes), From 4b747f79d5fd42f2b2a705dd2e3b43f2e3c19b2c Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:22:59 -0400 Subject: [PATCH 18/64] feat(hooks): pass env vars from services into UserHookHandler --- crates/forge_app/src/app.rs | 1 + .../forge_app/src/hooks/user_hook_handler.rs | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 162729c2e7..1e1f3f7e36 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -165,6 +165,7 @@ impl ForgeApp { let hook = if !user_hook_config.is_empty() { let user_handler = UserHookHandler::new( services.hook_command_service().clone(), + services.get_env_vars(), user_hook_config, environment.cwd.clone(), conversation.id.to_string(), diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 1b91462cd4..4968fa0457 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -1,8 +1,5 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - +use super::user_hook_executor::UserHookExecutor; +use crate::services::HookCommandService; use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, @@ -11,11 +8,12 @@ use forge_domain::{ UserHookEventName, UserHookMatcherGroup, }; use regex::Regex; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use tracing::{debug, warn}; -use super::user_hook_executor::UserHookExecutor; -use crate::services::HookCommandService; - /// Default timeout for hook commands (10 minutes). const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(600); @@ -47,8 +45,13 @@ impl UserHookHandler { /// * `session_id` - Current session/conversation ID. /// * `default_hook_timeout` - Default timeout in milliseconds for hook /// commands. - pub fn new(service: I, config: UserHookConfig, cwd: PathBuf, session_id: String) -> Self { - let mut env_vars = HashMap::new(); + pub fn new( + service: I, + mut env_vars: BTreeMap, + config: UserHookConfig, + cwd: PathBuf, + session_id: String, + ) -> Self { env_vars.insert( "FORGE_PROJECT_DIR".to_string(), cwd.to_string_lossy().to_string(), @@ -60,7 +63,7 @@ impl UserHookHandler { executor: UserHookExecutor::new(service), config, cwd, - env_vars, + env_vars: env_vars.into_iter().collect(), stop_hook_active: std::sync::Arc::new(AtomicBool::new(false)), } } @@ -625,6 +628,7 @@ mod tests { fn null_handler(config: UserHookConfig) -> UserHookHandler { UserHookHandler::new( NullInfra, + BTreeMap::new(), config, PathBuf::from("/tmp"), "sess-1".to_string(), From 50ecb0afc3cfb8775600f7fb65b71ba10bf2c53a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:26:37 +0000 Subject: [PATCH 19/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/user_hook_handler.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 4968fa0457..c247e3b6fa 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -1,5 +1,8 @@ -use super::user_hook_executor::UserHookExecutor; -use crate::services::HookCommandService; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, @@ -8,12 +11,11 @@ use forge_domain::{ UserHookEventName, UserHookMatcherGroup, }; use regex::Regex; -use std::collections::{BTreeMap, HashMap}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; use tracing::{debug, warn}; +use super::user_hook_executor::UserHookExecutor; +use crate::services::HookCommandService; + /// Default timeout for hook commands (10 minutes). const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(600); From 0daca786c852eaac53a2809bc9fc52097cacc457 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:45:06 -0400 Subject: [PATCH 20/64] refactor(hooks): propagate load errors and use FileInfoInfra for existence checks --- crates/forge_services/src/user_hook_config.rs | 165 +++++++++++++----- 1 file changed, 117 insertions(+), 48 deletions(-) diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index f85ec73781..1f340561c1 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -1,9 +1,8 @@ use std::path::Path; use std::sync::Arc; -use forge_app::{EnvironmentInfra, FileReaderInfra}; +use forge_app::{EnvironmentInfra, FileInfoInfra, FileReaderInfra}; use forge_domain::{UserHookConfig, UserSettings}; -use tracing::{debug, warn}; /// Loads and merges user hook configurations from the three settings file /// locations using infrastructure abstractions. @@ -21,72 +20,74 @@ impl ForgeUserHookConfigService { } } -impl ForgeUserHookConfigService { +impl ForgeUserHookConfigService { /// Loads a single settings file and extracts hook configuration. /// - /// Returns `None` if the file doesn't exist or is invalid. - async fn load_file(&self, path: &Path) -> Option { - let contents = match self.0.read_utf8(path).await { - Ok(c) => c, - Err(_) => return None, - }; + /// Returns `Ok(None)` if the file does not exist or cannot be read. + /// Returns `Err` if the file exists but fails to deserialize, including the file path in the + /// error message. + async fn load_file(&self, path: &Path) -> anyhow::Result> { + if !self.0.exists(path).await? { + return Ok(None); + } + let contents = self + .0 + .read_utf8(path) + .await + .map_err(|e| anyhow::anyhow!("Failed to read '{}': {}", path.display(), e))?; match serde_json::from_str::(&contents) { Ok(settings) => { if settings.hooks.is_empty() { - None + Ok(None) } else { - Some(settings.hooks) + Ok(Some(settings.hooks)) } } - Err(e) => { - warn!( - path = %path.display(), - error = %e, - "Failed to parse settings file for hooks" - ); - None - } + Err(e) => Err(anyhow::anyhow!( + "Failed to deserialize '{}': {}", + path.display(), + e + )), } } } #[async_trait::async_trait] -impl forge_app::UserHookConfigService +impl forge_app::UserHookConfigService for ForgeUserHookConfigService { async fn get_user_hook_config(&self) -> anyhow::Result { let env = self.0.get_environment(); - let mut config = UserHookConfig::new(); - // 1. User-level: ~/.forge/settings.json + // Collect all candidate paths in resolution order + let mut paths: Vec = Vec::new(); if let Some(home) = &env.home { - let user_settings_path = home.join("forge").join("settings.json"); - if let Some(user_config) = self.load_file(&user_settings_path).await { - debug!(path = %user_settings_path.display(), "Loaded user-level hook config"); - config.merge(user_config); - } + paths.push(home.join("forge").join("settings.json")); } + paths.push(env.cwd.join(".forge").join("settings.json")); + paths.push(env.cwd.join(".forge").join("settings.local.json")); - // 2. Project-level: .forge/settings.json - let project_settings_path = env.cwd.join(".forge").join("settings.json"); - if let Some(project_config) = self.load_file(&project_settings_path).await { - debug!(path = %project_settings_path.display(), "Loaded project-level hook config"); - config.merge(project_config); - } + // Load every file, keeping the (path, result) pairs + let results = + futures::future::join_all(paths.iter().map(|path| self.load_file(path))).await; + + // Collect the error message from every file that failed + let errors: Vec = results + .iter() + .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect(); - // 3. Project-local: .forge/settings.local.json - let local_settings_path = env.cwd.join(".forge").join("settings.local.json"); - if let Some(local_config) = self.load_file(&local_settings_path).await { - debug!(path = %local_settings_path.display(), "Loaded project-local hook config"); - config.merge(local_config); + if !errors.is_empty() { + return Err(anyhow::anyhow!("{}", errors.join("\n\n"))); } - if !config.is_empty() { - debug!( - event_count = config.events.len(), - "Merged user hook configuration" - ); + // Merge every successfully loaded config + let mut config = UserHookConfig::new(); + for result in results { + if let Ok(Some(file_config)) = result { + config.merge(file_config); + } } Ok(config) @@ -117,11 +118,11 @@ mod tests { } }"#, ) - .unwrap(); + .unwrap(); let service = fixture(None, PathBuf::from("/nonexistent")); - let actual = service.load_file(&settings_path).await; + let actual = service.load_file(&settings_path).await.unwrap(); assert!(actual.is_some()); let config = actual.unwrap(); assert_eq!( @@ -140,10 +141,56 @@ mod tests { let service = fixture(None, PathBuf::from("/nonexistent")); - let actual = service.load_file(&settings_path).await; + let actual = service.load_file(&settings_path).await.unwrap(); assert!(actual.is_none()); } + #[tokio::test] + async fn test_load_file_invalid_json_returns_error_with_path() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write(&settings_path, r#"{ invalid json }"#).unwrap(); + + let service = fixture(None, PathBuf::from("/nonexistent")); + + let actual = service.load_file(&settings_path).await; + assert!(actual.is_err()); + let err = actual.unwrap_err().to_string(); + assert!( + err.contains(&settings_path.display().to_string()), + "Error message should contain the file path, got: {err}" + ); + } + + #[tokio::test] + async fn test_get_user_hook_config_reports_all_invalid_files() { + let project_dir = tempfile::tempdir().unwrap(); + let project_forge_dir = project_dir.path().join(".forge"); + std::fs::create_dir_all(&project_forge_dir).unwrap(); + + // Both project files have invalid JSON + std::fs::write(project_forge_dir.join("settings.json"), r#"{ bad }"#).unwrap(); + std::fs::write( + project_forge_dir.join("settings.local.json"), + r#"{ also bad }"#, + ) + .unwrap(); + + let service = fixture(None, project_dir.path().to_path_buf()); + + let actual = service.get_user_hook_config().await; + assert!(actual.is_err()); + let err = actual.unwrap_err().to_string(); + assert!( + err.contains("settings.json"), + "Error should mention settings.json, got: {err}" + ); + assert!( + err.contains("settings.local.json"), + "Error should mention settings.local.json, got: {err}" + ); + } + #[tokio::test] async fn test_get_user_hook_config_nonexistent_paths() { let service = fixture( @@ -171,7 +218,7 @@ mod tests { } }"#, ) - .unwrap(); + .unwrap(); // Set up a project directory let project_dir = tempfile::tempdir().unwrap(); @@ -187,7 +234,7 @@ mod tests { } }"#, ) - .unwrap(); + .unwrap(); std::fs::write( project_forge_dir.join("settings.local.json"), r#"{ @@ -234,6 +281,28 @@ mod tests { cwd: PathBuf, } + #[async_trait::async_trait] + impl FileInfoInfra for TestInfra { + async fn is_binary(&self, _path: &Path) -> anyhow::Result { + Ok(false) + } + + async fn is_file(&self, path: &Path) -> anyhow::Result { + Ok(tokio::fs::metadata(path) + .await + .map(|m| m.is_file()) + .unwrap_or(false)) + } + + async fn exists(&self, path: &Path) -> anyhow::Result { + Ok(tokio::fs::try_exists(path).await.unwrap_or(false)) + } + + async fn file_size(&self, path: &Path) -> anyhow::Result { + Ok(tokio::fs::metadata(path).await?.len()) + } + } + #[async_trait::async_trait] impl FileReaderInfra for TestInfra { async fn read_utf8(&self, path: &Path) -> anyhow::Result { From 9358557fedab0a9eec2a55c845d54d2ed364d4e9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:46:52 +0000 Subject: [PATCH 21/64] [autofix.ci] apply automated fixes --- crates/forge_services/src/user_hook_config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index 1f340561c1..cee18c6439 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -24,8 +24,8 @@ impl ForgeUserHookConfigS /// Loads a single settings file and extracts hook configuration. /// /// Returns `Ok(None)` if the file does not exist or cannot be read. - /// Returns `Err` if the file exists but fails to deserialize, including the file path in the - /// error message. + /// Returns `Err` if the file exists but fails to deserialize, including the + /// file path in the error message. async fn load_file(&self, path: &Path) -> anyhow::Result> { if !self.0.exists(path).await? { return Ok(None); From bfc1bb7176206517be11ca1f76a5e3d99d849e77 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:56:35 -0400 Subject: [PATCH 22/64] refactor(hooks): move timeout enforcement from infra to executor layer --- .../forge_app/src/hooks/user_hook_executor.rs | 44 ++++++++++++++----- .../forge_app/src/hooks/user_hook_handler.rs | 2 - crates/forge_app/src/infra.rs | 11 ++--- crates/forge_app/src/services.rs | 9 ++-- crates/forge_infra/src/executor.rs | 34 +++----------- crates/forge_infra/src/forge_infra.rs | 4 +- crates/forge_repo/src/forge_repo.rs | 4 +- .../src/tool_services/hook_command.rs | 8 ++-- .../forge_services/src/tool_services/shell.rs | 1 - 9 files changed, 51 insertions(+), 66 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index 40047d90aa..cc267e77c5 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; -use forge_domain::HookExecutionResult; +use forge_domain::{CommandOutput, HookExecutionResult}; use tracing::debug; use crate::services::HookCommandService; @@ -26,12 +26,14 @@ impl UserHookExecutor { /// Executes a shell command, piping `input_json` to stdin and capturing /// stdout/stderr. /// + /// Applies `timeout_duration` by racing the service call against the + /// deadline. On timeout, returns a `HookExecutionResult` with + /// `exit_code: None` and a descriptive message in `stderr`. + /// /// # Arguments /// * `command` - The shell command string to execute. /// * `input_json` - JSON string to pipe to the command's stdin. - /// * `timeout` - per-hook timeout in milliseconds. Falls back to - /// environment configuration. Uses the built-in default (10 min) when - /// zero. + /// * `timeout_duration` - Maximum time to wait for the command. /// * `cwd` - Working directory for the command. /// * `env_vars` - Additional environment variables to set. /// @@ -52,16 +54,37 @@ impl UserHookExecutor { "Executing user hook command" ); - let output = self - .0 - .execute_command_with_input( + let result = tokio::time::timeout( + timeout_duration, + self.0.execute_command_with_input( command.to_string(), cwd.clone(), input_json.to_string(), - timeout_duration, env_vars.clone(), - ) - .await?; + ), + ) + .await; + + let output = match result { + Ok(Ok(output)) => output, + Ok(Err(e)) => return Err(e), + Err(_) => { + tracing::warn!( + command = command, + timeout_ms = timeout_duration.as_millis() as u64, + "Hook command timed out" + ); + CommandOutput { + command: command.to_string(), + exit_code: None, + stdout: String::new(), + stderr: format!( + "Hook command timed out after {}ms", + timeout_duration.as_millis() + ), + } + } + }; debug!( command = command, @@ -138,7 +161,6 @@ mod tests { command: String, _working_dir: PathBuf, _stdin_input: String, - _timeout: Duration, _env_vars: HashMap, ) -> anyhow::Result { let mut out = self.result.clone(); diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index c247e3b6fa..b537baf9f2 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -594,7 +594,6 @@ impl EventHandle> for UserHookHandl mod tests { use std::collections::HashMap; use std::path::PathBuf; - use std::time::Duration; use forge_domain::{ CommandOutput, HookExecutionResult, UserHookEntry, UserHookEventName, UserHookMatcherGroup, @@ -615,7 +614,6 @@ mod tests { command: String, _working_dir: PathBuf, _stdin_input: String, - _timeout: Duration, _env_vars: HashMap, ) -> anyhow::Result { Ok(CommandOutput { diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index d074788abc..93bf60090e 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -1,8 +1,6 @@ use std::collections::{BTreeMap, HashMap}; use std::hash::Hash; use std::path::{Path, PathBuf}; -use std::time::Duration; - use anyhow::Result; use bytes::Bytes; use forge_config::ForgeConfig; @@ -162,18 +160,16 @@ pub trait CommandInfra: Send + Sync { env_vars: Option>, ) -> anyhow::Result; - /// Executes a shell command with stdin input and a timeout. + /// Executes a shell command with stdin input. /// /// Pipes `stdin_input` to the process stdin, captures stdout and stderr, - /// and waits up to `timeout` for the process to complete. On timeout, - /// returns `CommandOutput` with `exit_code: None` and a timeout message in - /// `stderr`. + /// and waits for the process to complete. Timeout enforcement is handled + /// by the caller. /// /// # Arguments /// * `command` - Shell command string to execute. /// * `working_dir` - Working directory for the command. /// * `stdin_input` - Data to pipe to the process stdin. - /// * `timeout` - Maximum duration to wait for the command. /// * `env_vars` - Additional environment variables as key-value pairs. /// /// # Errors @@ -183,7 +179,6 @@ pub trait CommandInfra: Send + Sync { command: String, working_dir: PathBuf, stdin_input: String, - timeout: Duration, env_vars: HashMap, ) -> anyhow::Result; } diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index b90311269e..63eb1f690e 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -561,17 +561,15 @@ pub trait ProviderAuthService: Send + Sync { /// depends on a service rather than infrastructure directly. #[async_trait::async_trait] pub trait HookCommandService: Send + Sync { - /// Executes a shell command with stdin input and a timeout. + /// Executes a shell command with stdin input. /// - /// Pipes `stdin_input` to the process stdin and waits up to `timeout`. - /// Returns `CommandOutput` with `exit_code: None` and a timeout message in - /// `stderr` when the timeout expires. + /// Pipes `stdin_input` to the process stdin and captures stdout/stderr. + /// Timeout enforcement is handled by the caller. /// /// # Arguments /// * `command` - Shell command string to execute. /// * `working_dir` - Working directory for the command. /// * `stdin_input` - Data to pipe to the process stdin. - /// * `timeout` - Maximum duration to wait for the command. /// * `env_vars` - Additional environment variables as key-value pairs. /// /// # Errors @@ -581,7 +579,6 @@ pub trait HookCommandService: Send + Sync { command: String, working_dir: PathBuf, stdin_input: String, - timeout: Duration, env_vars: HashMap, ) -> anyhow::Result; } diff --git a/crates/forge_infra/src/executor.rs b/crates/forge_infra/src/executor.rs index bd18f276c0..a795556abc 100644 --- a/crates/forge_infra/src/executor.rs +++ b/crates/forge_infra/src/executor.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; use forge_app::CommandInfra; use forge_domain::{CommandOutput, ConsoleWriter as OutputPrinterTrait, Environment}; @@ -232,7 +231,6 @@ impl CommandInfra for ForgeCommandExecutorService { command: String, working_dir: PathBuf, stdin_input: String, - timeout: Duration, env_vars: HashMap, ) -> anyhow::Result { let mut prepared_command = self.prepare_command(&command, &working_dir, None); @@ -256,31 +254,13 @@ impl CommandInfra for ForgeCommandExecutorService { }); } - // Wait for the command with timeout - let result = tokio::time::timeout(timeout, child.wait_with_output()).await; - - match result { - Ok(Ok(output)) => Ok(CommandOutput { - command, - exit_code: output.status.code(), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }), - Ok(Err(e)) => Err(e.into()), - Err(_) => { - tracing::warn!( - command = command, - timeout_ms = timeout.as_millis() as u64, - "Hook command timed out" - ); - Ok(CommandOutput { - command, - exit_code: None, - stdout: String::new(), - stderr: format!("Hook command timed out after {}ms", timeout.as_millis()), - }) - } - } + let output = child.wait_with_output().await?; + Ok(CommandOutput { + command, + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) } } diff --git a/crates/forge_infra/src/forge_infra.rs b/crates/forge_infra/src/forge_infra.rs index dda28f8402..5b7857109b 100644 --- a/crates/forge_infra/src/forge_infra.rs +++ b/crates/forge_infra/src/forge_infra.rs @@ -2,7 +2,6 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::process::ExitStatus; use std::sync::Arc; -use std::time::Duration; use bytes::Bytes; use forge_app::{ @@ -234,11 +233,10 @@ impl CommandInfra for ForgeInfra { command: String, working_dir: PathBuf, stdin_input: String, - timeout: Duration, env_vars: HashMap, ) -> anyhow::Result { self.command_executor_service - .execute_command_with_input(command, working_dir, stdin_input, timeout, env_vars) + .execute_command_with_input(command, working_dir, stdin_input, env_vars) .await } } diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index c5a7c5c599..4289b74266 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -1,7 +1,6 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; use bytes::Bytes; use forge_app::{ @@ -466,11 +465,10 @@ where command: String, working_dir: PathBuf, stdin_input: String, - timeout: Duration, env_vars: HashMap, ) -> anyhow::Result { self.infra - .execute_command_with_input(command, working_dir, stdin_input, timeout, env_vars) + .execute_command_with_input(command, working_dir, stdin_input, env_vars) .await } } diff --git a/crates/forge_services/src/tool_services/hook_command.rs b/crates/forge_services/src/tool_services/hook_command.rs index b4a2f84526..3588bc0fa3 100644 --- a/crates/forge_services/src/tool_services/hook_command.rs +++ b/crates/forge_services/src/tool_services/hook_command.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; use forge_app::{CommandInfra, HookCommandService}; use forge_domain::CommandOutput; @@ -10,8 +9,8 @@ use forge_domain::CommandOutput; /// satisfies the [`HookCommandService`] contract. /// /// By delegating to the underlying infra this service avoids duplicating -/// process-spawning, stdin-piping, and timeout logic; those concerns live -/// entirely inside the `CommandInfra` implementation. +/// process-spawning and stdin-piping logic; those concerns live entirely inside +/// the `CommandInfra` implementation. #[derive(Clone)] pub struct ForgeHookCommandService(Arc); @@ -29,11 +28,10 @@ impl HookCommandService for ForgeHookCommandService { command: String, working_dir: PathBuf, stdin_input: String, - timeout: Duration, env_vars: HashMap, ) -> anyhow::Result { self.0 - .execute_command_with_input(command, working_dir, stdin_input, timeout, env_vars) + .execute_command_with_input(command, working_dir, stdin_input, env_vars) .await } } diff --git a/crates/forge_services/src/tool_services/shell.rs b/crates/forge_services/src/tool_services/shell.rs index 242e555cb1..1988779f77 100644 --- a/crates/forge_services/src/tool_services/shell.rs +++ b/crates/forge_services/src/tool_services/shell.rs @@ -114,7 +114,6 @@ mod tests { command: String, _working_dir: PathBuf, _stdin_input: String, - _timeout: std::time::Duration, _env_vars: std::collections::HashMap, ) -> anyhow::Result { Ok(forge_domain::CommandOutput { From f9eb27840d130ee846d8b3dc9057e168a3f6b308 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:59:14 +0000 Subject: [PATCH 23/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/infra.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index 93bf60090e..fa71f7b479 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, HashMap}; use std::hash::Hash; use std::path::{Path, PathBuf}; + use anyhow::Result; use bytes::Bytes; use forge_config::ForgeConfig; From 54c2a5abe9e86be3d0a5ced3b46a80b0633b25a0 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:40:01 -0400 Subject: [PATCH 24/64] refactor(hooks): remove hook_timeout_ms config field and use enum for event name --- crates/forge_app/src/hooks/user_hook_handler.rs | 2 +- crates/forge_config/src/config.rs | 4 ---- crates/forge_main/src/info.rs | 7 ++----- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index b537baf9f2..000135d7ad 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -278,7 +278,7 @@ impl EventHandle> for UserHookHan } let input = HookInput { - hook_event_name: "SessionStart".to_string(), + hook_event_name: UserHookEventName::SessionStart.to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), event_data: HookEventInput::SessionStart { source: "startup".to_string() }, diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 8c8bdf9fff..d152050a8d 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -62,10 +62,6 @@ pub struct ForgeConfig { /// cancelled. #[serde(default)] pub tool_timeout_secs: u64, - /// Default timeout in milliseconds for user hook commands. - /// Individual hooks can override this via their own `timeout` field. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hook_timeout_ms: Option, /// Whether to automatically open HTML dump files in the browser after /// creation. #[serde(default)] diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index 45779fbced..c7ad776831 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -75,7 +75,7 @@ impl Section { /// # Output Format /// /// ```text -/// +/// /// CONFIGURATION /// model gpt-4 /// provider openai @@ -387,10 +387,7 @@ impl From<&ForgeConfig> for Info { .add_key_value("ForgeCode Service URL", config.services_url.to_string()) .add_title("TOOL CONFIGURATION") .add_key_value("Tool Timeout", format!("{}s", config.tool_timeout_secs)) - .add_key_value( - "Hook Timeout", - format!("{}ms", config.hook_timeout_ms.unwrap_or(0)), - ) + .add_key("Hook Timed out") .add_key_value( "Max Image Size", format!("{} bytes", config.max_image_size_bytes), From 604041acf86f7cf87c5ca8c5a6ffee759f76eb6f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:42:33 +0000 Subject: [PATCH 25/64] [autofix.ci] apply automated fixes --- crates/forge_main/src/info.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index c7ad776831..49d5fd1e23 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -75,7 +75,7 @@ impl Section { /// # Output Format /// /// ```text -/// +/// /// CONFIGURATION /// model gpt-4 /// provider openai From efd1bc6dcb4d922376ab1eee3ef849efd2a21c83 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:58:34 -0400 Subject: [PATCH 26/64] chore(hooks): upgrade TODO/Note comments to FIXME with detailed explanations --- crates/forge_app/src/hooks/user_hook_handler.rs | 9 +++++---- crates/forge_domain/src/user_hook_config.rs | 9 ++++++--- crates/forge_domain/src/user_hook_io.rs | 6 ++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 000135d7ad..57d93ddc9e 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -286,7 +286,8 @@ impl EventHandle> for UserHookHan let results = self.execute_hooks(&hooks, &input).await; - // SessionStart hooks can provide additional context but not block + // FIXME: SessionStart hooks can provide additional context but not block; + // additional_context is detected here but never injected into the conversation. for result in &results { if let Some(output) = result.parse_output() && let Some(context) = &output.additional_context @@ -377,7 +378,7 @@ impl EventHandle> for UserHook _event: &EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { - // No user hook events map to Response currently + // FIXME: No user hook events map to Response currently Ok(()) } } @@ -394,7 +395,7 @@ impl EventHandle> for Use } let tool_name = event.payload.tool_call.name.as_str(); - // TODO: Add a tool name transformer to map tool names to Forge + // FIXME: Add a tool name transformer to map tool names to Forge // equivalents (e.g. "Bash" → "shell") so that hook configs written let groups = self.config.get_groups(&UserHookEventName::PreToolUse); let hooks = Self::find_matching_hooks(groups, Some(tool_name)); @@ -419,7 +420,7 @@ impl EventHandle> for Use match decision { PreToolUseDecision::Allow => Ok(()), PreToolUseDecision::AllowWithUpdate(_output) => { - // Note: Updating tool call input would require modifying the tool call + // FIXME: Updating tool call input would require modifying the tool call // in-flight, which would need changes to the orchestrator. // For now, we log and proceed. debug!( diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs index 89dbfb6929..e34c88dfed 100644 --- a/crates/forge_domain/src/user_hook_config.rs +++ b/crates/forge_domain/src/user_hook_config.rs @@ -62,7 +62,8 @@ pub enum UserHookEventName { PostToolUseFailure, /// Fired when the agent finishes responding. Can block stop to continue. Stop, - /// Fired when a notification is sent. + /// FIXME: Fired when a notification is sent; no lifecycle point fires this event and no + /// handler exists yet. Notification, /// Fired when a session starts or resumes. SessionStart, @@ -70,9 +71,11 @@ pub enum UserHookEventName { SessionEnd, /// Fired when a user prompt is submitted. UserPromptSubmit, - /// Fired before context compaction. + /// FIXME: Fired before context compaction; no lifecycle point fires this event and no + /// handler exists yet. PreCompact, - /// Fired after context compaction. + /// FIXME: Fired after context compaction; no lifecycle point fires this event and no + /// handler exists yet. PostCompact, } diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 3f260428c7..778ba4fa7a 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -75,7 +75,8 @@ pub enum HookEventInput { /// exit 0 with empty stdout. #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HookOutput { - /// Whether execution should continue. `false` halts processing. + /// FIXME: Whether execution should continue; deserialized from hook stdout but never + /// checked in any decision logic (`process_results`, `is_blocking()`). #[serde(default, rename = "continue", skip_serializing_if = "Option::is_none")] pub continue_execution: Option, @@ -111,7 +112,8 @@ pub struct HookOutput { )] pub additional_context: Option, - /// Reason for stopping (for Stop hooks). + /// FIXME: Reason for stopping (for Stop hooks); deserialized from hook stdout but never + /// consumed anywhere in decision logic. #[serde( default, rename = "stopReason", From f8034922553c4a583e9f65279bf1a866767a3a06 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:50:57 +0000 Subject: [PATCH 27/64] [autofix.ci] apply automated fixes --- crates/forge_domain/src/user_hook_config.rs | 12 ++++++------ crates/forge_domain/src/user_hook_io.rs | 9 +++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs index e34c88dfed..9a05d77786 100644 --- a/crates/forge_domain/src/user_hook_config.rs +++ b/crates/forge_domain/src/user_hook_config.rs @@ -62,8 +62,8 @@ pub enum UserHookEventName { PostToolUseFailure, /// Fired when the agent finishes responding. Can block stop to continue. Stop, - /// FIXME: Fired when a notification is sent; no lifecycle point fires this event and no - /// handler exists yet. + /// FIXME: Fired when a notification is sent; no lifecycle point fires this + /// event and no handler exists yet. Notification, /// Fired when a session starts or resumes. SessionStart, @@ -71,11 +71,11 @@ pub enum UserHookEventName { SessionEnd, /// Fired when a user prompt is submitted. UserPromptSubmit, - /// FIXME: Fired before context compaction; no lifecycle point fires this event and no - /// handler exists yet. + /// FIXME: Fired before context compaction; no lifecycle point fires this + /// event and no handler exists yet. PreCompact, - /// FIXME: Fired after context compaction; no lifecycle point fires this event and no - /// handler exists yet. + /// FIXME: Fired after context compaction; no lifecycle point fires this + /// event and no handler exists yet. PostCompact, } diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 778ba4fa7a..c66755a8b5 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -75,8 +75,9 @@ pub enum HookEventInput { /// exit 0 with empty stdout. #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HookOutput { - /// FIXME: Whether execution should continue; deserialized from hook stdout but never - /// checked in any decision logic (`process_results`, `is_blocking()`). + /// FIXME: Whether execution should continue; deserialized from hook stdout + /// but never checked in any decision logic (`process_results`, + /// `is_blocking()`). #[serde(default, rename = "continue", skip_serializing_if = "Option::is_none")] pub continue_execution: Option, @@ -112,8 +113,8 @@ pub struct HookOutput { )] pub additional_context: Option, - /// FIXME: Reason for stopping (for Stop hooks); deserialized from hook stdout but never - /// consumed anywhere in decision logic. + /// FIXME: Reason for stopping (for Stop hooks); deserialized from hook + /// stdout but never consumed anywhere in decision logic. #[serde( default, rename = "stopReason", From 63f86d7501fa2b6e3a02367a607bc1700b7d4702 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:06:41 -0400 Subject: [PATCH 28/64] feat(hooks): allow pretolluse hooks to update tool arguments --- crates/forge_app/src/hooks/compaction.rs | 2 +- crates/forge_app/src/hooks/doom_loop.rs | 2 +- .../forge_app/src/hooks/title_generation.rs | 18 +- crates/forge_app/src/hooks/tracing.rs | 32 +- .../forge_app/src/hooks/user_hook_handler.rs | 453 +++++++++++++++++- crates/forge_app/src/orch.rs | 48 +- crates/forge_domain/src/hook.rs | 119 +++-- crates/forge_domain/src/user_hook_io.rs | 74 ++- 8 files changed, 620 insertions(+), 128 deletions(-) diff --git a/crates/forge_app/src/hooks/compaction.rs b/crates/forge_app/src/hooks/compaction.rs index 76e58df83d..2c2eef848b 100644 --- a/crates/forge_app/src/hooks/compaction.rs +++ b/crates/forge_app/src/hooks/compaction.rs @@ -31,7 +31,7 @@ impl CompactionHandler { impl EventHandle> for CompactionHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { if let Some(context) = &conversation.context { diff --git a/crates/forge_app/src/hooks/doom_loop.rs b/crates/forge_app/src/hooks/doom_loop.rs index 3515b74e7b..3ec3c667cc 100644 --- a/crates/forge_app/src/hooks/doom_loop.rs +++ b/crates/forge_app/src/hooks/doom_loop.rs @@ -222,7 +222,7 @@ impl DoomLoopDetector { impl EventHandle> for DoomLoopDetector { async fn handle( &self, - event: &EventData, + event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { if let Some(consecutive_calls) = self.detect_from_conversation(conversation) { diff --git a/crates/forge_app/src/hooks/title_generation.rs b/crates/forge_app/src/hooks/title_generation.rs index debcae12e3..a00de076b5 100644 --- a/crates/forge_app/src/hooks/title_generation.rs +++ b/crates/forge_app/src/hooks/title_generation.rs @@ -42,7 +42,7 @@ impl TitleGenerationHandler { impl EventHandle> for TitleGenerationHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { if conversation.title.is_some() { @@ -94,7 +94,7 @@ impl EventHandle> for TitleGenerationHa impl EventHandle> for TitleGenerationHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { // Atomically transition InProgress → Awaiting, extracting the receiver @@ -221,7 +221,7 @@ mod tests { conversation.title = Some("existing".into()); handler - .handle(&event(StartPayload), &mut conversation) + .handle(&mut event(StartPayload), &mut conversation) .await .unwrap(); @@ -238,7 +238,7 @@ mod tests { .insert(conversation.id, TitleTask::InProgress(rx)); handler - .handle(&event(StartPayload), &mut conversation) + .handle(&mut event(StartPayload), &mut conversation) .await .unwrap(); @@ -260,7 +260,7 @@ mod tests { .insert(conversation.id, TitleTask::Awaiting); handler - .handle(&event(StartPayload), &mut conversation) + .handle(&mut event(StartPayload), &mut conversation) .await .unwrap(); @@ -279,7 +279,7 @@ mod tests { .insert(conversation.id, TitleTask::Done("existing".into())); handler - .handle(&event(StartPayload), &mut conversation) + .handle(&mut event(StartPayload), &mut conversation) .await .unwrap(); @@ -299,7 +299,7 @@ mod tests { .insert(conversation.id, TitleTask::InProgress(rx)); handler - .handle(&event(EndPayload), &mut conversation) + .handle(&mut event(EndPayload), &mut conversation) .await .unwrap(); @@ -321,7 +321,7 @@ mod tests { .insert(conversation.id, TitleTask::InProgress(rx)); handler - .handle(&event(EndPayload), &mut conversation) + .handle(&mut event(EndPayload), &mut conversation) .await .unwrap(); @@ -345,7 +345,7 @@ mod tests { joins.push(tokio::spawn(async move { barrier.wait().await; handler - .handle(&event(StartPayload), &mut conv) + .handle(&mut event(StartPayload), &mut conv) .await .unwrap(); })); diff --git a/crates/forge_app/src/hooks/tracing.rs b/crates/forge_app/src/hooks/tracing.rs index 94755f2b2c..9242c25f51 100644 --- a/crates/forge_app/src/hooks/tracing.rs +++ b/crates/forge_app/src/hooks/tracing.rs @@ -28,7 +28,7 @@ impl TracingHandler { impl EventHandle> for TracingHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { debug!( @@ -46,7 +46,7 @@ impl EventHandle> for TracingHandler { impl EventHandle> for TracingHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { // Request events are logged but don't need specific logging per request @@ -59,7 +59,7 @@ impl EventHandle> for TracingHandler { impl EventHandle> for TracingHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { let message = &event.payload.message; @@ -91,7 +91,7 @@ impl EventHandle> for TracingHandler { impl EventHandle> for TracingHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { let tool_call = &event.payload.tool_call; @@ -112,7 +112,7 @@ impl EventHandle> for TracingHandler { impl EventHandle> for TracingHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { let tool_call = &event.payload.tool_call; @@ -137,7 +137,7 @@ impl EventHandle> for TracingHandler { impl EventHandle> for TracingHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { if let Some(title) = &conversation.title { @@ -176,20 +176,20 @@ mod tests { async fn test_tracing_handler_start() { let handler = TracingHandler::new(); let mut conversation = Conversation::generate(); - let event = EventData::new(test_agent(), test_model_id(), StartPayload); + let mut event = EventData::new(test_agent(), test_model_id(), StartPayload); // Should not panic - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); } #[tokio::test] async fn test_tracing_handler_request() { let handler = TracingHandler::new(); let mut conversation = Conversation::generate(); - let event = EventData::new(test_agent(), test_model_id(), RequestPayload::new(0)); + let mut event = EventData::new(test_agent(), test_model_id(), RequestPayload::new(0)); // Should not panic - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); } #[tokio::test] @@ -206,10 +206,10 @@ mod tests { finish_reason: None, phase: None, }; - let event = EventData::new(test_agent(), test_model_id(), ResponsePayload::new(message)); + let mut event = EventData::new(test_agent(), test_model_id(), ResponsePayload::new(message)); // Should not panic - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); } #[tokio::test] @@ -225,23 +225,23 @@ mod tests { let result = ToolResult::new(ToolName::from("test-tool")) .call_id(ToolCallId::new("test-id")) .failure(anyhow::anyhow!("Test error")); - let event = EventData::new( + let mut event = EventData::new( test_agent(), test_model_id(), ToolcallEndPayload::new(tool_call, result), ); // Should log warning but not panic - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); } #[tokio::test] async fn test_tracing_handler_end_with_title() { let handler = TracingHandler::new(); let mut conversation = Conversation::generate().title(Some("Test Title".to_string())); - let event = EventData::new(test_agent(), test_model_id(), EndPayload); + let mut event = EventData::new(test_agent(), test_model_id(), EndPayload); // Should log debug message with title - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); } } diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 57d93ddc9e..938435d6fe 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -7,10 +7,11 @@ use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, HookExecutionResult, HookInput, HookOutput, RequestPayload, ResponsePayload, Role, - StartPayload, ToolcallEndPayload, ToolcallStartPayload, UserHookConfig, UserHookEntry, - UserHookEventName, UserHookMatcherGroup, + StartPayload, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, UserHookConfig, + UserHookEntry, UserHookEventName, UserHookMatcherGroup, }; use regex::Regex; +use serde_json::Value; use tracing::{debug, warn}; use super::user_hook_executor::UserHookExecutor; @@ -263,7 +264,7 @@ enum PreToolUseDecision { impl EventHandle> for UserHookHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { if !self.has_hooks(&UserHookEventName::SessionStart) { @@ -307,7 +308,7 @@ impl EventHandle> for UserHookHan impl EventHandle> for UserHookHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { // Only fire on the first request of a turn (user-submitted prompt). @@ -375,7 +376,7 @@ impl EventHandle> for UserHookH impl EventHandle> for UserHookHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { // FIXME: No user hook events map to Response currently @@ -387,18 +388,19 @@ impl EventHandle> for UserHook impl EventHandle> for UserHookHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { if !self.has_hooks(&UserHookEventName::PreToolUse) { return Ok(()); } - let tool_name = event.payload.tool_call.name.as_str(); + // Use owned String to avoid borrow conflicts when mutating event later. + let tool_name = event.payload.tool_call.name.as_str().to_string(); // FIXME: Add a tool name transformer to map tool names to Forge // equivalents (e.g. "Bash" → "shell") so that hook configs written let groups = self.config.get_groups(&UserHookEventName::PreToolUse); - let hooks = Self::find_matching_hooks(groups, Some(tool_name)); + let hooks = Self::find_matching_hooks(groups, Some(tool_name.as_str())); if hooks.is_empty() { return Ok(()); @@ -411,7 +413,10 @@ impl EventHandle> for Use hook_event_name: "PreToolUse".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::PreToolUse { tool_name: tool_name.to_string(), tool_input }, + event_data: HookEventInput::PreToolUse { + tool_name: tool_name.clone(), + tool_input, + }, }; let results = self.execute_hooks(&hooks, &input).await; @@ -419,19 +424,20 @@ impl EventHandle> for Use match decision { PreToolUseDecision::Allow => Ok(()), - PreToolUseDecision::AllowWithUpdate(_output) => { - // FIXME: Updating tool call input would require modifying the tool call - // in-flight, which would need changes to the orchestrator. - // For now, we log and proceed. - debug!( - tool_name = tool_name, - "PreToolUse hook returned updatedInput (not yet supported for modification)" - ); + PreToolUseDecision::AllowWithUpdate(output) => { + if let Some(updated_input) = output.updated_input { + event.payload.tool_call.arguments = + ToolCallArguments::Parsed(Value::Object(updated_input)); + debug!( + tool_name = tool_name.as_str(), + "PreToolUse hook updated tool input" + ); + } Ok(()) } PreToolUseDecision::Block(reason) => { debug!( - tool_name = tool_name, + tool_name = tool_name.as_str(), reason = reason.as_str(), "PreToolUse hook blocked tool call" ); @@ -452,7 +458,7 @@ impl EventHandle> for Use impl EventHandle> for UserHookHandler { async fn handle( &self, - event: &EventData, + event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { let is_error = event.payload.result.is_error(); @@ -519,7 +525,7 @@ impl EventHandle> for UserH impl EventHandle> for UserHookHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { // Fire SessionEnd hooks @@ -817,4 +823,411 @@ mod tests { assert!(handler.has_hooks(&UserHookEventName::PreToolUse)); assert!(!handler.has_hooks(&UserHookEventName::Stop)); } + + #[test] + fn test_process_pre_tool_use_output_allow_with_update_detected() { + // A hook that returns updatedInput should produce AllowWithUpdate with the + // correct updated_input value. + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("echo safe"))]); + assert!( + matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) + ); + } + + #[tokio::test] + async fn test_allow_with_update_modifies_tool_call_arguments() { + // When a PreToolUse hook returns updatedInput, the handler must + // overwrite event.payload.tool_call.arguments with the new value. + use forge_domain::{ + Agent, EventData, ModelId, ProviderId, ToolCallArguments, ToolCallFull, + ToolcallStartPayload, + }; + + let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + + // NullInfra returns exit_code=0 with empty stdout (Allow), so we need a custom + // infra that returns updatedInput JSON. + #[derive(Clone)] + struct UpdateInfra; + + #[async_trait::async_trait] + impl HookCommandService for UpdateInfra { + async fn execute_command_with_input( + &self, + command: String, + _working_dir: PathBuf, + _stdin_input: String, + _env_vars: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), + stderr: String::new(), + }) + } + } + + let handler = UserHookHandler::new( + UpdateInfra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test".to_string(), + ); + + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + let original_args = ToolCallArguments::from_json(r#"{"command": "rm -rf /"}"#); + let tool_call = ToolCallFull::new("shell").arguments(original_args); + let mut event = EventData::new( + agent, + ModelId::new("test-model"), + ToolcallStartPayload::new(tool_call), + ); + let mut conversation = forge_domain::Conversation::generate(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + let actual_args = event.payload.tool_call.arguments.parse().unwrap(); + let expected_args = serde_json::json!({"command": "echo safe"}); + assert_eq!(actual_args, expected_args); + } + + #[test] + fn test_allow_with_update_none_updated_input_leaves_args_unchanged() { + // When HookOutput has updated_input = None (e.g. only + // `{"permissionDecision": "allow"}`), AllowWithUpdate should not + // overwrite the original arguments. + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"permissionDecision": "allow"}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + // permissionDecision "allow" with no updatedInput => plain Allow + assert!(matches!(actual, PreToolUseDecision::Allow)); + } + + #[test] + fn test_allow_with_update_empty_object() { + // updatedInput is an empty object — still a valid update. + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {}}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + let expected_map = serde_json::Map::new(); + assert!( + matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) + ); + } + + #[test] + fn test_allow_with_update_complex_nested_input() { + // updatedInput with nested objects and arrays. + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"file_path": "/safe/path", "options": {"recursive": true, "depth": 3}, "tags": ["a", "b"]}}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + let expected_map = serde_json::Map::from_iter([ + ("file_path".to_string(), serde_json::json!("/safe/path")), + ("options".to_string(), serde_json::json!({"recursive": true, "depth": 3})), + ("tags".to_string(), serde_json::json!(["a", "b"])), + ]); + assert!( + matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) + ); + } + + #[test] + fn test_block_takes_priority_over_updated_input() { + // If a hook returns both decision=block AND updatedInput, the block + // must win because blocking is checked before updatedInput. + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "nope", "updatedInput": {"command": "echo safe"}}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "nope")); + } + + #[test] + fn test_deny_takes_priority_over_updated_input() { + // permissionDecision=deny should block even if updatedInput is present. + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"permissionDecision": "deny", "reason": "forbidden", "updatedInput": {"command": "echo safe"}}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "forbidden")); + } + + #[test] + fn test_exit_code_2_blocks_even_with_updated_input_in_stdout() { + // Exit code 2 is a hard block regardless of stdout content. + let results = vec![HookExecutionResult { + exit_code: Some(2), + stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), + stderr: "hard block".to_string(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("hard block"))); + } + + #[test] + fn test_multiple_results_first_update_wins() { + // When multiple hooks run and the first returns updatedInput, that + // result is used (iteration stops at first non-Allow decision). + let results = vec![ + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "first"}}"#.to_string(), + stderr: String::new(), + }, + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "second"}}"#.to_string(), + stderr: String::new(), + }, + ]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("first"))]); + assert!( + matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) + ); + } + + #[test] + fn test_multiple_results_block_before_update() { + // A block from an earlier hook prevents a later hook's updatedInput + // from being applied. + let results = vec![ + HookExecutionResult { + exit_code: Some(2), + stdout: String::new(), + stderr: "blocked first".to_string(), + }, + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), + stderr: String::new(), + }, + ]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("blocked first"))); + } + + #[test] + fn test_non_blocking_error_then_update() { + // A non-blocking error (exit 1) from the first hook is logged but + // doesn't prevent a subsequent hook from returning updatedInput. + let results = vec![ + HookExecutionResult { + exit_code: Some(1), + stdout: String::new(), + stderr: "warning".to_string(), + }, + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), + stderr: String::new(), + }, + ]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("safe"))]); + assert!( + matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) + ); + } + + #[tokio::test] + async fn test_allow_with_update_no_updated_input_preserves_original() { + // When the hook returns exit 0 with empty stdout (no updatedInput), + // the original tool call arguments must remain untouched. + use forge_domain::{ + Agent, EventData, ModelId, ProviderId, ToolCallArguments, ToolCallFull, + ToolcallStartPayload, + }; + + let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + // NullInfra returns exit 0 + empty stdout => Allow + let handler = null_handler(config); + + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + let original_args = ToolCallArguments::from_json(r#"{"command": "ls"}"#); + let tool_call = ToolCallFull::new("shell").arguments(original_args); + let mut event = EventData::new( + agent, + ModelId::new("test-model"), + ToolcallStartPayload::new(tool_call), + ); + let mut conversation = forge_domain::Conversation::generate(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + // Arguments must still be the original value + let actual_args = event.payload.tool_call.arguments.parse().unwrap(); + let expected_args = serde_json::json!({"command": "ls"}); + assert_eq!(actual_args, expected_args); + } + + #[tokio::test] + async fn test_allow_with_update_replaces_unparsed_with_parsed() { + // Original arguments are Unparsed (raw string from LLM). After + // AllowWithUpdate, they should become Parsed(Value). + use forge_domain::{ + Agent, EventData, ModelId, ProviderId, ToolCallArguments, ToolCallFull, + ToolcallStartPayload, + }; + + let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + + #[derive(Clone)] + struct UpdateInfra2; + + #[async_trait::async_trait] + impl HookCommandService for UpdateInfra2 { + async fn execute_command_with_input( + &self, + command: String, + _working_dir: PathBuf, + _stdin_input: String, + _env_vars: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: r#"{"updatedInput": {"file_path": "/safe/file.txt", "content": "hello"}}"#.to_string(), + stderr: String::new(), + }) + } + } + + let handler = UserHookHandler::new( + UpdateInfra2, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test2".to_string(), + ); + + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + // Start with Unparsed arguments + let original_args = ToolCallArguments::from_json(r#"{"file_path": "/etc/passwd", "content": "evil"}"#); + assert!(matches!(original_args, ToolCallArguments::Unparsed(_))); + + let tool_call = ToolCallFull::new("write").arguments(original_args); + let mut event = EventData::new( + agent, + ModelId::new("test-model"), + ToolcallStartPayload::new(tool_call), + ); + let mut conversation = forge_domain::Conversation::generate(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + // After update, arguments should be Parsed + assert!(matches!( + event.payload.tool_call.arguments, + ToolCallArguments::Parsed(_) + )); + let actual_args = event.payload.tool_call.arguments.parse().unwrap(); + let expected_args = serde_json::json!({"file_path": "/safe/file.txt", "content": "hello"}); + assert_eq!(actual_args, expected_args); + } + + #[tokio::test] + async fn test_block_returns_error_and_preserves_original_args() { + // When a hook blocks, handle() returns Err and the event arguments + // remain unchanged. + use forge_domain::{ + Agent, EventData, ModelId, ProviderId, ToolCallArguments, ToolCallFull, + ToolcallStartPayload, + }; + + let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + + #[derive(Clone)] + struct BlockInfra; + + #[async_trait::async_trait] + impl HookCommandService for BlockInfra { + async fn execute_command_with_input( + &self, + command: String, + _working_dir: PathBuf, + _stdin_input: String, + _env_vars: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "dangerous operation".to_string(), + }) + } + } + + let handler = UserHookHandler::new( + BlockInfra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-block".to_string(), + ); + + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + let original_args = ToolCallArguments::from_json(r#"{"command": "rm -rf /"}"#); + let tool_call = ToolCallFull::new("shell").arguments(original_args); + let mut event = EventData::new( + agent, + ModelId::new("test-model"), + ToolcallStartPayload::new(tool_call), + ); + let mut conversation = forge_domain::Conversation::generate(); + + let result = handler.handle(&mut event, &mut conversation).await; + + // Should be an error + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("blocked by PreToolUse hook")); + assert!(err_msg.contains("dangerous operation")); + + // Arguments must still be the original value (not modified) + let actual_args = event.payload.tool_call.arguments.parse().unwrap(); + let expected_args = serde_json::json!({"command": "rm -rf /"}); + assert_eq!(actual_args, expected_args); + } } diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index 636ed3441d..d31bc794e4 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -87,17 +87,19 @@ impl Orchestrator { // Fire the ToolcallStart lifecycle event. // If a hook returns an error (e.g., PreToolUse hook blocked the // call), skip execution and record an error result instead. - let toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( + // A PreToolUse hook may also modify the tool call arguments in-flight + // via the AllowWithUpdate path. + let mut toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( self.agent.clone(), self.agent.model.clone(), ToolcallStartPayload::new(tool_call.clone()), )); let hook_result = self .hook - .handle(&toolcall_start_event, &mut self.conversation) + .handle(&mut toolcall_start_event, &mut self.conversation) .await; - let tool_result = if let Err(hook_err) = hook_result { + let (effective_tool_call, tool_result) = if let Err(hook_err) = hook_result { // Hook blocked this tool call — notify the UI and produce an // error ToolResult so the model sees feedback without aborting. self.send(ChatResponse::HookError { @@ -105,22 +107,30 @@ impl Orchestrator { reason: hook_err.to_string(), }) .await?; - ToolResult::from(tool_call.clone()).failure(hook_err) + let result = ToolResult::from(tool_call.clone()).failure(hook_err); + (tool_call.clone(), result) } else { - // Execute the tool normally - self.services - .call(&self.agent, tool_context, tool_call.clone()) - .await + // Extract the (possibly modified) tool call from the event. + // A PreToolUse hook may have updated the tool call arguments. + let effective = match toolcall_start_event { + LifecycleEvent::ToolcallStart(data) => data.payload.tool_call, + _ => unreachable!("ToolcallStart event cannot change variant"), + }; + let result = self + .services + .call(&self.agent, tool_context, effective.clone()) + .await; + (effective, result) }; // Fire the ToolcallEnd lifecycle event (fires on both success and failure) - let toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( + let mut toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( self.agent.clone(), self.agent.model.clone(), - ToolcallEndPayload::new(tool_call.clone(), tool_result.clone()), + ToolcallEndPayload::new(effective_tool_call.clone(), tool_result.clone()), )); self.hook - .handle(&toolcall_end_event, &mut self.conversation) + .handle(&mut toolcall_end_event, &mut self.conversation) .await?; // Send the end notification for system tools and not agent as a tool @@ -130,7 +140,7 @@ impl Orchestrator { } // Ensure all tool calls and results are recorded // Adding task completion records is critical for compaction to work correctly - tool_call_records.push((tool_call.clone(), tool_result)); + tool_call_records.push((effective_tool_call, tool_result)); } Ok(tool_call_records) @@ -201,13 +211,13 @@ impl Orchestrator { let mut context = self.conversation.context.clone().unwrap_or_default(); // Fire the Start lifecycle event - let start_event = LifecycleEvent::Start(EventData::new( + let mut start_event = LifecycleEvent::Start(EventData::new( self.agent.clone(), model_id.clone(), StartPayload, )); self.hook - .handle(&start_event, &mut self.conversation) + .handle(&mut start_event, &mut self.conversation) .await?; // Signals that the loop should suspend (task may or may not be completed) @@ -229,13 +239,13 @@ impl Orchestrator { self.services.update(self.conversation.clone()).await?; // Fire the Request lifecycle event - let request_event = LifecycleEvent::Request(EventData::new( + let mut request_event = LifecycleEvent::Request(EventData::new( self.agent.clone(), model_id.clone(), RequestPayload::new(request_count), )); self.hook - .handle(&request_event, &mut self.conversation) + .handle(&mut request_event, &mut self.conversation) .await?; let message = crate::retry::retry_with_config( @@ -269,13 +279,13 @@ impl Orchestrator { .await?; // Fire the Response lifecycle event - let response_event = LifecycleEvent::Response(EventData::new( + let mut response_event = LifecycleEvent::Response(EventData::new( self.agent.clone(), model_id.clone(), ResponsePayload::new(message.clone()), )); self.hook - .handle(&response_event, &mut self.conversation) + .handle(&mut response_event, &mut self.conversation) .await?; // Turn is completed, if finish_reason is 'stop'. Gemini models return stop as @@ -378,7 +388,7 @@ impl Orchestrator { // Fire the End lifecycle event (title will be set here by the hook) self.hook .handle( - &LifecycleEvent::End(EventData::new( + &mut LifecycleEvent::End(EventData::new( self.agent.clone(), model_id.clone(), EndPayload, diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index 47579d7a43..4a0751807e 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -126,12 +126,12 @@ pub trait EventHandle: Send + Sync { /// Handles a lifecycle event and potentially modifies the conversation /// /// # Arguments - /// * `event` - The lifecycle event that occurred + /// * `event` - The lifecycle event that occurred (mutable to allow in-flight modification) /// * `conversation` - The current conversation state (mutable) /// /// # Errors /// Returns an error if the event handling fails - async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()>; + async fn handle(&self, event: &mut T, conversation: &mut Conversation) -> anyhow::Result<()>; } /// Extension trait for combining event handlers @@ -166,7 +166,7 @@ impl + 'static> EventHandleExt fo // Implement EventHandle for Box to allow using boxed handlers #[async_trait] impl EventHandle for Box> { - async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()> { + async fn handle(&self, event: &mut T, conversation: &mut Conversation) -> anyhow::Result<()> { (**self).handle(event, conversation).await } } @@ -326,10 +326,10 @@ impl Hook { impl EventHandle for Hook { async fn handle( &self, - event: &LifecycleEvent, + event: &mut LifecycleEvent, conversation: &mut Conversation, ) -> anyhow::Result<()> { - match &event { + match event { LifecycleEvent::Start(data) => self.on_start.handle(data, conversation).await, LifecycleEvent::End(data) => self.on_end.handle(data, conversation).await, LifecycleEvent::Request(data) => self.on_request.handle(data, conversation).await, @@ -354,7 +354,7 @@ struct CombinedHandler(Box>, Box EventHandle for CombinedHandler { - async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()> { + async fn handle(&self, event: &mut T, conversation: &mut Conversation) -> anyhow::Result<()> { // Run the first handler self.0.handle(event, conversation).await?; // Run the second handler with the cloned event @@ -371,7 +371,7 @@ pub struct NoOpHandler; #[async_trait] impl EventHandle for NoOpHandler { - async fn handle(&self, _: &T, _: &mut Conversation) -> anyhow::Result<()> { + async fn handle(&self, _: &mut T, _: &mut Conversation) -> anyhow::Result<()> { Ok(()) } } @@ -379,17 +379,17 @@ impl EventHandle for NoOpHandler { #[async_trait] impl EventHandle for F where - F: Fn(&T, &mut Conversation) -> Fut + Send + Sync, + F: Fn(&mut T, &mut Conversation) -> Fut + Send + Sync, Fut: std::future::Future> + Send, { - async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()> { + async fn handle(&self, event: &mut T, conversation: &mut Conversation) -> anyhow::Result<()> { (self)(event, conversation).await } } impl From for Box> where - F: Fn(&T, &mut Conversation) -> Fut + Send + Sync + 'static, + F: Fn(&mut T, &mut Conversation) -> Fut + Send + Sync + 'static, Fut: std::future::Future> + Send + 'static, { fn from(handler: F) -> Self { @@ -432,7 +432,7 @@ mod tests { let events_clone = events.clone(); let hook = Hook::default().on_start( - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events_clone.clone(); let event = event.clone(); async move { @@ -445,7 +445,7 @@ mod tests { let mut conversation = Conversation::generate(); hook.handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await @@ -466,7 +466,7 @@ mod tests { let hook = Hook::default() .on_start({ let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::Start(event.clone()); async move { @@ -477,7 +477,7 @@ mod tests { }) .on_end({ let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::End(event.clone()); async move { @@ -488,7 +488,7 @@ mod tests { }) .on_request({ let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::Request(event.clone()); async move { @@ -502,21 +502,21 @@ mod tests { // Test Start event hook.handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await .unwrap(); // Test End event hook.handle( - &LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), + &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), &mut conversation, ) .await .unwrap(); // Test Request event hook.handle( - &LifecycleEvent::Request(EventData::new( + &mut LifecycleEvent::Request(EventData::new( test_agent(), test_model_id(), RequestPayload::new(1), @@ -553,7 +553,7 @@ mod tests { let hook = Hook::new( { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::Start(event.clone()); async move { @@ -564,7 +564,7 @@ mod tests { }, { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::End(event.clone()); async move { @@ -575,7 +575,7 @@ mod tests { }, { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::Request(event.clone()); async move { @@ -586,7 +586,7 @@ mod tests { }, { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::Response(event.clone()); async move { @@ -597,7 +597,7 @@ mod tests { }, { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::ToolcallStart(event.clone()); async move { @@ -608,7 +608,7 @@ mod tests { }, { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::ToolcallEnd(event.clone()); async move { @@ -658,8 +658,8 @@ mod tests { )), ]; - for event in all_events { - hook.handle(&event, &mut conversation).await.unwrap(); + for mut event in all_events { + hook.handle(&mut event, &mut conversation).await.unwrap(); } let handled = events.lock().unwrap(); @@ -671,7 +671,7 @@ mod tests { let title = std::sync::Arc::new(std::sync::Mutex::new(None)); let hook = Hook::default().on_start({ let title = title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let title = title.clone(); async move { *title.lock().unwrap() = Some("Modified title".to_string()); @@ -684,7 +684,7 @@ mod tests { assert!(title.lock().unwrap().is_none()); hook.handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await @@ -708,7 +708,7 @@ mod tests { let hook1 = Hook::default().on_start({ let counter = counter1.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let counter = counter.clone(); async move { *counter.lock().unwrap() += 1; @@ -719,7 +719,7 @@ mod tests { let hook2 = Hook::default().on_start({ let counter = counter2.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let counter = counter.clone(); async move { *counter.lock().unwrap() += 1; @@ -732,7 +732,7 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await @@ -749,7 +749,7 @@ mod tests { let hook1 = Hook::default().on_start({ let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = event.clone(); async move { @@ -761,7 +761,7 @@ mod tests { let hook2 = Hook::default().on_start({ let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = event.clone(); async move { @@ -773,7 +773,7 @@ mod tests { let hook3 = Hook::default().on_start({ let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = event.clone(); async move { @@ -787,7 +787,7 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await @@ -808,7 +808,7 @@ mod tests { let hook1 = Hook::default() .on_start({ let start_title = start_title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let start_title = start_title.clone(); async move { *start_title.lock().unwrap() = Some("Start".to_string()); @@ -818,7 +818,7 @@ mod tests { }) .on_end({ let end_title = end_title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let end_title = end_title.clone(); async move { *end_title.lock().unwrap() = Some("End".to_string()); @@ -835,7 +835,7 @@ mod tests { // Test Start event combined .handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await @@ -845,7 +845,7 @@ mod tests { // Test End event combined .handle( - &LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), + &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), &mut conversation, ) .await @@ -860,7 +860,7 @@ mod tests { let handler1 = { let counter = counter1.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let counter = counter.clone(); async move { *counter.lock().unwrap() += 1; @@ -871,7 +871,7 @@ mod tests { let handler2 = { let counter = counter2.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let counter = counter.clone(); async move { *counter.lock().unwrap() += 1; @@ -885,7 +885,7 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &EventData::new(test_agent(), test_model_id(), StartPayload), + &mut EventData::new(test_agent(), test_model_id(), StartPayload), &mut conversation, ) .await @@ -903,7 +903,7 @@ mod tests { let handler1 = { let counter = counter1.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let counter = counter.clone(); async move { *counter.lock().unwrap() += 1; @@ -914,7 +914,7 @@ mod tests { let handler2 = { let counter = counter2.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let counter = counter.clone(); async move { *counter.lock().unwrap() += 1; @@ -928,7 +928,7 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &EventData::new(test_agent(), test_model_id(), StartPayload), + &mut EventData::new(test_agent(), test_model_id(), StartPayload), &mut conversation, ) .await @@ -938,14 +938,13 @@ mod tests { assert_eq!(*counter1.lock().unwrap(), 1); assert_eq!(*counter2.lock().unwrap(), 1); } - #[tokio::test] async fn test_event_handle_ext_chain() { let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let handler1 = { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = event.clone(); async move { @@ -957,7 +956,7 @@ mod tests { let handler2 = { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = event.clone(); async move { @@ -969,7 +968,7 @@ mod tests { let handler3 = { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = event.clone(); async move { @@ -986,7 +985,7 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &EventData::new(test_agent(), test_model_id(), StartPayload), + &mut EventData::new(test_agent(), test_model_id(), StartPayload), &mut conversation, ) .await @@ -1006,7 +1005,7 @@ mod tests { let start_handler = { let start_title = start_title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let start_title = start_title.clone(); async move { *start_title.lock().unwrap() = Some("Started".to_string()); @@ -1017,7 +1016,7 @@ mod tests { let logging_handler = { let events = events.clone(); - move |event: &EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, _conversation: &mut Conversation| { let events = events.clone(); let event = event.clone(); async move { @@ -1035,7 +1034,7 @@ mod tests { let mut conversation = Conversation::generate(); hook.handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await @@ -1053,7 +1052,7 @@ mod tests { let hook = Hook::default() .on_start({ let start_title = start_title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let start_title = start_title.clone(); async move { *start_title.lock().unwrap() = Some("Started".to_string()); @@ -1063,7 +1062,7 @@ mod tests { }) .on_end({ let end_title = end_title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let end_title = end_title.clone(); async move { *end_title.lock().unwrap() = Some("Ended".to_string()); @@ -1075,7 +1074,7 @@ mod tests { // Test using handle() directly (EventHandle trait) let mut conversation = Conversation::generate(); hook.handle( - &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), &mut conversation, ) .await @@ -1083,7 +1082,7 @@ mod tests { assert_eq!(*start_title.lock().unwrap(), Some("Started".to_string())); hook.handle( - &LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), + &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), &mut conversation, ) .await @@ -1098,7 +1097,7 @@ mod tests { let handler1 = { let hook1_title = hook1_title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let hook1_title = hook1_title.clone(); async move { *hook1_title.lock().unwrap() = Some("Started".to_string()); @@ -1108,7 +1107,7 @@ mod tests { }; let handler2 = { let hook2_title = hook2_title.clone(); - move |_event: &EventData, _conversation: &mut Conversation| { + move |_event: &mut EventData, _conversation: &mut Conversation| { let hook2_title = hook2_title.clone(); async move { *hook2_title.lock().unwrap() = Some("Ended".to_string()); @@ -1123,7 +1122,7 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &EventData::new(test_agent(), test_model_id(), StartPayload), + &mut EventData::new(test_agent(), test_model_id(), StartPayload), &mut conversation, ) .await diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index c66755a8b5..fb3a305318 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; /// Exit code constants for hook script results. pub mod exit_codes { @@ -103,7 +103,7 @@ pub struct HookOutput { rename = "updatedInput", skip_serializing_if = "Option::is_none" )] - pub updated_input: Option, + pub updated_input: Option>, /// Additional context to inject into the conversation. #[serde( @@ -332,4 +332,74 @@ mod tests { assert!(!fixture.is_blocking_exit()); assert!(fixture.blocking_message().is_none()); } + + // --- Schema validation tests for updatedInput --- + + #[test] + fn test_updated_input_valid_object_parsed() { + let stdout = r#"{"updatedInput": {"command": "echo safe"}}"#; + let actual = HookOutput::parse(stdout); + let expected_map = Map::from_iter([("command".to_string(), Value::String("echo safe".to_string()))]); + assert_eq!(actual.updated_input, Some(expected_map)); + } + + #[test] + fn test_updated_input_string_rejected_falls_back_to_default() { + // updatedInput is a string, not an object => serde rejects it, + // entire parse falls back to default (updated_input = None). + let stdout = r#"{"updatedInput": "not an object"}"#; + let actual = HookOutput::parse(stdout); + assert_eq!(actual.updated_input, None); + } + + #[test] + fn test_updated_input_number_rejected_falls_back_to_default() { + let stdout = r#"{"updatedInput": 42}"#; + let actual = HookOutput::parse(stdout); + assert_eq!(actual.updated_input, None); + } + + #[test] + fn test_updated_input_array_rejected_falls_back_to_default() { + let stdout = r#"{"updatedInput": [1, 2, 3]}"#; + let actual = HookOutput::parse(stdout); + assert_eq!(actual.updated_input, None); + } + + #[test] + fn test_updated_input_bool_rejected_falls_back_to_default() { + let stdout = r#"{"updatedInput": true}"#; + let actual = HookOutput::parse(stdout); + assert_eq!(actual.updated_input, None); + } + + #[test] + fn test_updated_input_null_treated_as_none() { + // JSON null for an Option field => None (not Some(empty map)) + let stdout = r#"{"updatedInput": null}"#; + let actual = HookOutput::parse(stdout); + assert_eq!(actual.updated_input, None); + } + + #[test] + fn test_updated_input_nested_object_accepted() { + let stdout = r#"{"updatedInput": {"a": {"b": [1, 2]}, "c": true}}"#; + let actual = HookOutput::parse(stdout); + let expected_map = Map::from_iter([ + ("a".to_string(), serde_json::json!({"b": [1, 2]})), + ("c".to_string(), Value::Bool(true)), + ]); + assert_eq!(actual.updated_input, Some(expected_map)); + } + + #[test] + fn test_malformed_updated_input_preserves_other_fields() { + // When updatedInput is invalid, the entire HookOutput parse fails + // and falls back to default. This means other fields like `decision` + // are also lost. This is the expected behavior — a malformed hook + // output is treated as if the hook returned nothing. + let stdout = r#"{"decision": "block", "updatedInput": "bad"}"#; + let actual = HookOutput::parse(stdout); + assert_eq!(actual, HookOutput::default()); + } } From 193f556a3cbbd132f9bac7a4885b96acb55a5733 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:35:43 +0000 Subject: [PATCH 29/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/tracing.rs | 3 ++- .../forge_app/src/hooks/user_hook_handler.rs | 26 +++++++++++------- crates/forge_domain/src/hook.rs | 27 ++++++++++++++----- crates/forge_domain/src/user_hook_io.rs | 5 +++- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/crates/forge_app/src/hooks/tracing.rs b/crates/forge_app/src/hooks/tracing.rs index 9242c25f51..29c681d18b 100644 --- a/crates/forge_app/src/hooks/tracing.rs +++ b/crates/forge_app/src/hooks/tracing.rs @@ -206,7 +206,8 @@ mod tests { finish_reason: None, phase: None, }; - let mut event = EventData::new(test_agent(), test_model_id(), ResponsePayload::new(message)); + let mut event = + EventData::new(test_agent(), test_model_id(), ResponsePayload::new(message)); // Should not panic handler.handle(&mut event, &mut conversation).await.unwrap(); diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 938435d6fe..49c0c4e372 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -413,10 +413,7 @@ impl EventHandle> for Use hook_event_name: "PreToolUse".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::PreToolUse { - tool_name: tool_name.clone(), - tool_input, - }, + event_data: HookEventInput::PreToolUse { tool_name: tool_name.clone(), tool_input }, }; let results = self.execute_hooks(&hooks, &input).await; @@ -834,7 +831,8 @@ mod tests { stderr: String::new(), }]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); - let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("echo safe"))]); + let expected_map = + serde_json::Map::from_iter([("command".to_string(), serde_json::json!("echo safe"))]); assert!( matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) ); @@ -945,7 +943,10 @@ mod tests { let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = serde_json::Map::from_iter([ ("file_path".to_string(), serde_json::json!("/safe/path")), - ("options".to_string(), serde_json::json!({"recursive": true, "depth": 3})), + ( + "options".to_string(), + serde_json::json!({"recursive": true, "depth": 3}), + ), ("tags".to_string(), serde_json::json!(["a", "b"])), ]); assert!( @@ -1007,7 +1008,8 @@ mod tests { }, ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); - let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("first"))]); + let expected_map = + serde_json::Map::from_iter([("command".to_string(), serde_json::json!("first"))]); assert!( matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) ); @@ -1050,7 +1052,8 @@ mod tests { }, ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); - let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("safe"))]); + let expected_map = + serde_json::Map::from_iter([("command".to_string(), serde_json::json!("safe"))]); assert!( matches!(&actual, PreToolUseDecision::AllowWithUpdate(output) if output.updated_input == Some(expected_map)) ); @@ -1119,7 +1122,9 @@ mod tests { Ok(forge_domain::CommandOutput { command, exit_code: Some(0), - stdout: r#"{"updatedInput": {"file_path": "/safe/file.txt", "content": "hello"}}"#.to_string(), + stdout: + r#"{"updatedInput": {"file_path": "/safe/file.txt", "content": "hello"}}"# + .to_string(), stderr: String::new(), }) } @@ -1139,7 +1144,8 @@ mod tests { ModelId::new("test-model"), ); // Start with Unparsed arguments - let original_args = ToolCallArguments::from_json(r#"{"file_path": "/etc/passwd", "content": "evil"}"#); + let original_args = + ToolCallArguments::from_json(r#"{"file_path": "/etc/passwd", "content": "evil"}"#); assert!(matches!(original_args, ToolCallArguments::Unparsed(_))); let tool_call = ToolCallFull::new("write").arguments(original_args); diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index 4a0751807e..dd0f02dde2 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -126,7 +126,8 @@ pub trait EventHandle: Send + Sync { /// Handles a lifecycle event and potentially modifies the conversation /// /// # Arguments - /// * `event` - The lifecycle event that occurred (mutable to allow in-flight modification) + /// * `event` - The lifecycle event that occurred (mutable to allow + /// in-flight modification) /// * `conversation` - The current conversation state (mutable) /// /// # Errors @@ -597,7 +598,8 @@ mod tests { }, { let events = events.clone(); - move |event: &mut EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, + _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::ToolcallStart(event.clone()); async move { @@ -608,7 +610,8 @@ mod tests { }, { let events = events.clone(); - move |event: &mut EventData, _conversation: &mut Conversation| { + move |event: &mut EventData, + _conversation: &mut Conversation| { let events = events.clone(); let event = LifecycleEvent::ToolcallEnd(event.clone()); async move { @@ -732,7 +735,11 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new( + test_agent(), + test_model_id(), + StartPayload, + )), &mut conversation, ) .await @@ -787,7 +794,11 @@ mod tests { let mut conversation = Conversation::generate(); combined .handle( - &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new( + test_agent(), + test_model_id(), + StartPayload, + )), &mut conversation, ) .await @@ -835,7 +846,11 @@ mod tests { // Test Start event combined .handle( - &mut LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), + &mut LifecycleEvent::Start(EventData::new( + test_agent(), + test_model_id(), + StartPayload, + )), &mut conversation, ) .await diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index fb3a305318..90ebc86620 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -339,7 +339,10 @@ mod tests { fn test_updated_input_valid_object_parsed() { let stdout = r#"{"updatedInput": {"command": "echo safe"}}"#; let actual = HookOutput::parse(stdout); - let expected_map = Map::from_iter([("command".to_string(), Value::String("echo safe".to_string()))]); + let expected_map = Map::from_iter([( + "command".to_string(), + Value::String("echo safe".to_string()), + )]); assert_eq!(actual.updated_input, Some(expected_map)); } From ecf22b62b8df44acb8d05eaa971e1f7f41cce413 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:45:05 -0400 Subject: [PATCH 30/64] refactor(hooks): render hook feedback with template elements --- .../forge_app/src/hooks/user_hook_handler.rs | 28 +++++++++++-------- forge.schema.json | 9 ------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 49c0c4e372..58467d4e76 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -12,6 +12,7 @@ use forge_domain::{ }; use regex::Regex; use serde_json::Value; +use forge_template::Element; use tracing::{debug, warn}; use super::user_hook_executor::UserHookExecutor; @@ -359,9 +360,11 @@ impl EventHandle> for UserHookH ); // Inject feedback so the model sees why the prompt was flagged. if let Some(context) = conversation.context.as_mut() { - let feedback_msg = format!( - "\nUserPromptSubmit\nblocked\n{reason}\n" - ); + let feedback_msg = Element::new("hook_feedback") + .append(Element::new("event").text("UserPromptSubmit")) + .append(Element::new("status").text("blocked")) + .append(Element::new("reason").text(&reason)) + .render(); context .messages .push(ContextMessage::user(feedback_msg, None).into()); @@ -504,10 +507,12 @@ impl EventHandle> for UserH ); // Inject feedback as a user message if let Some(context) = conversation.context.as_mut() { - let feedback_msg = format!( - "\n{}\n{}\nblocked\n{}\n", - event_name, tool_name, reason - ); + let feedback_msg = Element::new("hook_feedback") + .append(Element::new("event").text(event_name.to_string())) + .append(Element::new("tool").text(tool_name)) + .append(Element::new("status").text("blocked")) + .append(Element::new("reason").text(&reason)) + .render(); context .messages .push(forge_domain::ContextMessage::user(feedback_msg, None).into()); @@ -577,10 +582,11 @@ impl EventHandle> for UserHookHandl ); // Inject a message to continue the conversation if let Some(context) = conversation.context.as_mut() { - let continue_msg = format!( - "\nStop\ncontinue\n{}\n", - reason - ); + let continue_msg = Element::new("hook_feedback") + .append(Element::new("event").text("Stop")) + .append(Element::new("status").text("continue")) + .append(Element::new("reason").text(&reason)) + .render(); context .messages .push(forge_domain::ContextMessage::user(continue_msg, None).into()); diff --git a/forge.schema.json b/forge.schema.json index 3c87127701..9ba1a41bba 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -56,15 +56,6 @@ "null" ] }, - "hook_timeout_ms": { - "description": "Default timeout in milliseconds for user hook commands.\nIndividual hooks can override this via their own `timeout` field.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, "http": { "description": "HTTP client settings including proxy, TLS, and timeout configuration.", "anyOf": [ From 0f1a70f55aed40477bf133905752db86d54217f8 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:45:19 -0400 Subject: [PATCH 31/64] chore(gitignore): remove hooksref ignore entries --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 66351b131c..077bfbece7 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,3 @@ Cargo.lock **/.forge/request.body.json node_modules/ bench/__pycache__ -/hooksref* -#/cc From 4385f8aee696ce85740237ebd1fab208fe6bbd1b Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:47:49 -0400 Subject: [PATCH 32/64] fix tests --- crates/forge_services/src/user_hook_config.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index cee18c6439..22b8c665be 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -349,10 +349,6 @@ mod tests { env } - fn get_config(&self) -> forge_config::ForgeConfig { - Default::default() - } - async fn update_environment( &self, _ops: Vec, From e473f5f792c6d092d891acc08b47f34ac9094d8a Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:50:35 -0400 Subject: [PATCH 33/64] test(hooks): load hook config fixtures via include_str --- crates/forge_domain/src/user_hook_config.rs | 65 +++------------------ 1 file changed, 7 insertions(+), 58 deletions(-) diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs index 9a05d77786..f542edf130 100644 --- a/crates/forge_domain/src/user_hook_config.rs +++ b/crates/forge_domain/src/user_hook_config.rs @@ -151,19 +151,7 @@ mod tests { #[test] fn test_deserialize_pre_tool_use_hook() { - let json = r#"{ - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "echo 'blocked'" - } - ] - } - ] - }"#; + let json = include_str!("fixtures/hook_pre_tool_use.json"); let actual: UserHookConfig = serde_json::from_str(json).unwrap(); let groups = actual.get_groups(&UserHookEventName::PreToolUse); @@ -180,17 +168,7 @@ mod tests { #[test] fn test_deserialize_multiple_events() { - let json = r#"{ - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "pre.sh" }] } - ], - "PostToolUse": [ - { "hooks": [{ "type": "command", "command": "post.sh" }] } - ], - "Stop": [ - { "hooks": [{ "type": "command", "command": "stop.sh" }] } - ] - }"#; + let json = include_str!("fixtures/hook_multiple_events.json"); let actual: UserHookConfig = serde_json::from_str(json).unwrap(); @@ -206,15 +184,7 @@ mod tests { #[test] fn test_deserialize_hook_with_timeout() { - let json = r#"{ - "PreToolUse": [ - { - "hooks": [ - { "type": "command", "command": "slow.sh", "timeout": 30000 } - ] - } - ] - }"#; + let json = include_str!("fixtures/hook_with_timeout.json"); let actual: UserHookConfig = serde_json::from_str(json).unwrap(); let groups = actual.get_groups(&UserHookEventName::PreToolUse); @@ -224,19 +194,8 @@ mod tests { #[test] fn test_merge_configs() { - let json1 = r#"{ - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "hook1.sh" }] } - ] - }"#; - let json2 = r#"{ - "PreToolUse": [ - { "matcher": "Write", "hooks": [{ "type": "command", "command": "hook2.sh" }] } - ], - "Stop": [ - { "hooks": [{ "type": "command", "command": "stop.sh" }] } - ] - }"#; + let json1 = include_str!("fixtures/hook_merge_config_1.json"); + let json2 = include_str!("fixtures/hook_merge_config_2.json"); let mut actual: UserHookConfig = serde_json::from_str(json1).unwrap(); let config2: UserHookConfig = serde_json::from_str(json2).unwrap(); @@ -248,13 +207,7 @@ mod tests { #[test] fn test_deserialize_settings_with_hooks() { - let json = r#"{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } - ] - } - }"#; + let json = include_str!("fixtures/hook_settings_with_hooks.json"); let actual: UserSettings = serde_json::from_str(json).unwrap(); @@ -278,11 +231,7 @@ mod tests { #[test] fn test_no_matcher_group_fires_unconditionally() { - let json = r#"{ - "PostToolUse": [ - { "hooks": [{ "type": "command", "command": "always.sh" }] } - ] - }"#; + let json = include_str!("fixtures/hook_no_matcher.json"); let actual: UserHookConfig = serde_json::from_str(json).unwrap(); let groups = actual.get_groups(&UserHookEventName::PostToolUse); From 33d9c132a0d5522822b91c0d7fd19146119c7125 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:07:01 -0400 Subject: [PATCH 34/64] feat(hooks): block execution when continue is false and use stop reason --- .../forge_app/src/hooks/user_hook_handler.rs | 57 ++++++++++--- crates/forge_domain/src/user_hook_io.rs | 81 +++++++++++++++++-- 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 58467d4e76..fcca67c910 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -180,9 +180,7 @@ impl UserHookHandler { if let Some(output) = result.parse_output() && output.is_blocking() { - let reason = output - .reason - .unwrap_or_else(|| "Hook blocked execution".to_string()); + let reason = output.blocking_reason("Hook blocked execution"); return Some(reason); } @@ -215,17 +213,13 @@ impl UserHookHandler { if let Some(output) = result.parse_output() { // Check permission decision if output.permission_decision.as_deref() == Some("deny") { - let reason = output - .reason - .unwrap_or_else(|| "Tool execution denied by hook".to_string()); + let reason = output.blocking_reason("Tool execution denied by hook"); return PreToolUseDecision::Block(reason); } // Check generic block decision if output.is_blocking() { - let reason = output - .reason - .unwrap_or_else(|| "Hook blocked tool execution".to_string()); + let reason = output.blocking_reason("Hook blocked tool execution"); return PreToolUseDecision::Block(reason); } @@ -1242,4 +1236,49 @@ mod tests { let expected_args = serde_json::json!({"command": "rm -rf /"}); assert_eq!(actual_args, expected_args); } + + #[test] + fn test_process_results_blocking_continue_false() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"continue": false, "stopReason": "task complete"}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_results(&results); + assert_eq!(actual, Some("task complete".to_string())); + } + + #[test] + fn test_process_pre_tool_use_output_block_on_continue_false() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"continue": false, "stopReason": "no more tools"}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_pre_tool_use_output(&results); + assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "no more tools")); + } + + #[test] + fn test_process_results_stop_reason_fallback() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "stopReason": "fallback reason"}"#.to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_results(&results); + assert_eq!(actual, Some("fallback reason".to_string())); + } + + #[test] + fn test_process_results_reason_over_stop_reason() { + let results = vec![HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "primary", "stopReason": "secondary"}"# + .to_string(), + stderr: String::new(), + }]; + let actual = UserHookHandler::::process_results(&results); + assert_eq!(actual, Some("primary".to_string())); + } } diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 90ebc86620..e5608eac41 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -75,9 +75,9 @@ pub enum HookEventInput { /// exit 0 with empty stdout. #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HookOutput { - /// FIXME: Whether execution should continue; deserialized from hook stdout - /// but never checked in any decision logic (`process_results`, - /// `is_blocking()`). + /// Whether execution should continue. When `false`, prevents the agent's + /// execution loop from continuing. Checked by `is_blocking()` alongside + /// `decision` and `permission_decision`. #[serde(default, rename = "continue", skip_serializing_if = "Option::is_none")] pub continue_execution: Option, @@ -113,8 +113,9 @@ pub struct HookOutput { )] pub additional_context: Option, - /// FIXME: Reason for stopping (for Stop hooks); deserialized from hook - /// stdout but never consumed anywhere in decision logic. + /// Reason for stopping, used as a fallback reason when + /// `continue_execution` is `false`. Consumed by `process_results` and + /// `process_pre_tool_use_output` as a fallback when `reason` is absent. #[serde( default, rename = "stopReason", @@ -136,6 +137,15 @@ impl HookOutput { pub fn is_blocking(&self) -> bool { self.decision.as_deref() == Some("block") || self.permission_decision.as_deref() == Some("deny") + || self.continue_execution == Some(false) + } + + /// Returns the blocking reason, preferring `reason` over `stop_reason`. + pub fn blocking_reason(&self, default: &str) -> String { + self.reason + .clone() + .or_else(|| self.stop_reason.clone()) + .unwrap_or_else(|| default.to_string()) } } @@ -289,6 +299,67 @@ mod tests { assert!(!fixture.is_blocking()); } + #[test] + fn test_hook_output_is_blocking_continue_false() { + let fixture = HookOutput { + continue_execution: Some(false), + ..Default::default() + }; + assert!(fixture.is_blocking()); + } + + #[test] + fn test_hook_output_is_not_blocking_continue_true() { + let fixture = HookOutput { + continue_execution: Some(true), + ..Default::default() + }; + assert!(!fixture.is_blocking()); + } + + #[test] + fn test_hook_output_is_not_blocking_continue_none() { + let fixture = HookOutput { continue_execution: None, ..Default::default() }; + assert!(!fixture.is_blocking()); + } + + #[test] + fn test_hook_output_continue_false_with_stop_reason_parses_and_blocks() { + let stdout = r#"{"continue": false, "stopReason": "done"}"#; + let actual = HookOutput::parse(stdout); + assert!(actual.is_blocking()); + assert_eq!(actual.continue_execution, Some(false)); + assert_eq!(actual.stop_reason, Some("done".to_string())); + } + + #[test] + fn test_blocking_reason_prefers_reason_over_stop_reason() { + let fixture = HookOutput { + reason: Some("primary".to_string()), + stop_reason: Some("secondary".to_string()), + ..Default::default() + }; + let actual = fixture.blocking_reason("default"); + assert_eq!(actual, "primary"); + } + + #[test] + fn test_blocking_reason_falls_back_to_stop_reason() { + let fixture = HookOutput { + stop_reason: Some("fallback".to_string()), + ..Default::default() + }; + let actual = fixture.blocking_reason("default"); + assert_eq!(actual, "fallback"); + } + + #[test] + fn test_blocking_reason_uses_default_when_both_none() { + let fixture = HookOutput::default(); + let actual = fixture.blocking_reason("default reason"); + assert_eq!(actual, "default reason"); + } + #[test] fn test_hook_execution_result_blocking() { let fixture = HookExecutionResult { From ec50b00bf49d7fb46d0304e211ef37f3608a4a52 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:17:30 -0400 Subject: [PATCH 35/64] test(hooks): add hook config json fixtures --- .../src/fixtures/hook_merge_config_1.json | 5 +++++ .../src/fixtures/hook_merge_config_2.json | 8 ++++++++ .../src/fixtures/hook_multiple_events.json | 11 +++++++++++ .../forge_domain/src/fixtures/hook_no_matcher.json | 5 +++++ .../src/fixtures/hook_pre_tool_use.json | 13 +++++++++++++ .../src/fixtures/hook_settings_with_hooks.json | 7 +++++++ .../src/fixtures/hook_with_timeout.json | 9 +++++++++ 7 files changed, 58 insertions(+) create mode 100644 crates/forge_domain/src/fixtures/hook_merge_config_1.json create mode 100644 crates/forge_domain/src/fixtures/hook_merge_config_2.json create mode 100644 crates/forge_domain/src/fixtures/hook_multiple_events.json create mode 100644 crates/forge_domain/src/fixtures/hook_no_matcher.json create mode 100644 crates/forge_domain/src/fixtures/hook_pre_tool_use.json create mode 100644 crates/forge_domain/src/fixtures/hook_settings_with_hooks.json create mode 100644 crates/forge_domain/src/fixtures/hook_with_timeout.json diff --git a/crates/forge_domain/src/fixtures/hook_merge_config_1.json b/crates/forge_domain/src/fixtures/hook_merge_config_1.json new file mode 100644 index 0000000000..2079f740d7 --- /dev/null +++ b/crates/forge_domain/src/fixtures/hook_merge_config_1.json @@ -0,0 +1,5 @@ +{ + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "hook1.sh" }] } + ] +} diff --git a/crates/forge_domain/src/fixtures/hook_merge_config_2.json b/crates/forge_domain/src/fixtures/hook_merge_config_2.json new file mode 100644 index 0000000000..b09c020a1c --- /dev/null +++ b/crates/forge_domain/src/fixtures/hook_merge_config_2.json @@ -0,0 +1,8 @@ +{ + "PreToolUse": [ + { "matcher": "Write", "hooks": [{ "type": "command", "command": "hook2.sh" }] } + ], + "Stop": [ + { "hooks": [{ "type": "command", "command": "stop.sh" }] } + ] +} diff --git a/crates/forge_domain/src/fixtures/hook_multiple_events.json b/crates/forge_domain/src/fixtures/hook_multiple_events.json new file mode 100644 index 0000000000..01096bcadd --- /dev/null +++ b/crates/forge_domain/src/fixtures/hook_multiple_events.json @@ -0,0 +1,11 @@ +{ + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "pre.sh" }] } + ], + "PostToolUse": [ + { "hooks": [{ "type": "command", "command": "post.sh" }] } + ], + "Stop": [ + { "hooks": [{ "type": "command", "command": "stop.sh" }] } + ] +} diff --git a/crates/forge_domain/src/fixtures/hook_no_matcher.json b/crates/forge_domain/src/fixtures/hook_no_matcher.json new file mode 100644 index 0000000000..10699df53c --- /dev/null +++ b/crates/forge_domain/src/fixtures/hook_no_matcher.json @@ -0,0 +1,5 @@ +{ + "PostToolUse": [ + { "hooks": [{ "type": "command", "command": "always.sh" }] } + ] +} diff --git a/crates/forge_domain/src/fixtures/hook_pre_tool_use.json b/crates/forge_domain/src/fixtures/hook_pre_tool_use.json new file mode 100644 index 0000000000..9d27b21463 --- /dev/null +++ b/crates/forge_domain/src/fixtures/hook_pre_tool_use.json @@ -0,0 +1,13 @@ +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo 'blocked'" + } + ] + } + ] +} diff --git a/crates/forge_domain/src/fixtures/hook_settings_with_hooks.json b/crates/forge_domain/src/fixtures/hook_settings_with_hooks.json new file mode 100644 index 0000000000..5b251d1d73 --- /dev/null +++ b/crates/forge_domain/src/fixtures/hook_settings_with_hooks.json @@ -0,0 +1,7 @@ +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } + ] + } +} diff --git a/crates/forge_domain/src/fixtures/hook_with_timeout.json b/crates/forge_domain/src/fixtures/hook_with_timeout.json new file mode 100644 index 0000000000..2c78f8296e --- /dev/null +++ b/crates/forge_domain/src/fixtures/hook_with_timeout.json @@ -0,0 +1,9 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { "type": "command", "command": "slow.sh", "timeout": 30000 } + ] + } + ] +} From 16176e6c6dc11866be904c042d9fd9cd78eaff71 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:19:13 +0000 Subject: [PATCH 36/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/user_hook_handler.rs | 2 +- crates/forge_domain/src/user_hook_io.rs | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index fcca67c910..d776c31fa7 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -10,9 +10,9 @@ use forge_domain::{ StartPayload, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, }; +use forge_template::Element; use regex::Regex; use serde_json::Value; -use forge_template::Element; use tracing::{debug, warn}; use super::user_hook_executor::UserHookExecutor; diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index e5608eac41..8e2c39d4c9 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -301,19 +301,13 @@ mod tests { #[test] fn test_hook_output_is_blocking_continue_false() { - let fixture = HookOutput { - continue_execution: Some(false), - ..Default::default() - }; + let fixture = HookOutput { continue_execution: Some(false), ..Default::default() }; assert!(fixture.is_blocking()); } #[test] fn test_hook_output_is_not_blocking_continue_true() { - let fixture = HookOutput { - continue_execution: Some(true), - ..Default::default() - }; + let fixture = HookOutput { continue_execution: Some(true), ..Default::default() }; assert!(!fixture.is_blocking()); } From ccd4b5f8347a25d1b96d2fda10bdcdbfb1cda1b8 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:21:38 -0400 Subject: [PATCH 37/64] feat(hooks): suppress blocked prompts and continue on stop hook block --- .../forge_app/src/hooks/user_hook_executor.rs | 2 +- .../forge_app/src/hooks/user_hook_handler.rs | 817 +++++++++++++++++- crates/forge_app/src/orch.rs | 87 +- crates/forge_domain/src/hook.rs | 32 + crates/forge_domain/src/user_hook_config.rs | 12 +- crates/forge_domain/src/user_hook_io.rs | 26 + 6 files changed, 936 insertions(+), 40 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index cc267e77c5..225d7dbce9 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -89,7 +89,7 @@ impl UserHookExecutor { debug!( command = command, exit_code = ?output.exit_code, - stdout_len = output.stdout.len(), + stdout_len = output.stdout, stderr_len = output.stderr.len(), "Hook command completed" ); diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index d776c31fa7..2ea76c5e46 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -6,9 +6,9 @@ use std::time::Duration; use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, - HookExecutionResult, HookInput, HookOutput, RequestPayload, ResponsePayload, Role, - StartPayload, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, UserHookConfig, - UserHookEntry, UserHookEventName, UserHookMatcherGroup, + HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, + ResponsePayload, Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, + ToolcallStartPayload, UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, }; use forge_template::Element; use regex::Regex; @@ -98,14 +98,18 @@ impl UserHookHandler { false } }, - (Some(_), None) => { - // Matcher specified but no subject to match against; skip - false - } (None, _) => { // No matcher means unconditional match true } + (Some(x), _) if x.is_empty() => { + // Empty matcher is treated as unconditional (same as None) + true + } + (Some(_), None) => { + // Matcher specified but no subject to match against; skip + false + } }; if matches { @@ -354,15 +358,24 @@ impl EventHandle> for UserHookH ); // Inject feedback so the model sees why the prompt was flagged. if let Some(context) = conversation.context.as_mut() { - let feedback_msg = Element::new("hook_feedback") - .append(Element::new("event").text("UserPromptSubmit")) - .append(Element::new("status").text("blocked")) - .append(Element::new("reason").text(&reason)) + let feedback_msg = Element::new("important") + .text( + "A UserPromptSubmit hook has blocked this prompt. \ + You MUST acknowledge this in your next response.", + ) + .append( + Element::new("hook_feedback") + .append(Element::new("event").text("UserPromptSubmit")) + .append(Element::new("status").text("blocked")) + .append(Element::new("reason").text(&reason)), + ) .render(); context .messages .push(ContextMessage::user(feedback_msg, None).into()); } + // Signal the orchestrator to suppress this prompt entirely. + return Err(anyhow::Error::from(PromptSuppressed(reason))); } Ok(()) @@ -405,12 +418,22 @@ impl EventHandle> for Use let tool_input = serde_json::to_value(&event.payload.tool_call.arguments).unwrap_or_default(); + let tool_use_id = event + .payload + .tool_call + .call_id + .as_ref() + .map(|id| id.as_str().to_string()); let input = HookInput { hook_event_name: "PreToolUse".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::PreToolUse { tool_name: tool_name.clone(), tool_input }, + event_data: HookEventInput::PreToolUse { + tool_name: tool_name.clone(), + tool_input, + tool_use_id, + }, }; let results = self.execute_hooks(&hooks, &input).await; @@ -477,6 +500,12 @@ impl EventHandle> for UserH let tool_input = serde_json::to_value(&event.payload.tool_call.arguments).unwrap_or_default(); let tool_response = serde_json::to_value(&event.payload.result.output).unwrap_or_default(); + let tool_use_id = event + .payload + .tool_call + .call_id + .as_ref() + .map(|id| id.as_str().to_string()); let input = HookInput { hook_event_name: event_name.to_string(), @@ -486,6 +515,7 @@ impl EventHandle> for UserH tool_name: tool_name.to_string(), tool_input, tool_response, + tool_use_id, }, }; @@ -501,11 +531,18 @@ impl EventHandle> for UserH ); // Inject feedback as a user message if let Some(context) = conversation.context.as_mut() { - let feedback_msg = Element::new("hook_feedback") - .append(Element::new("event").text(event_name.to_string())) - .append(Element::new("tool").text(tool_name)) - .append(Element::new("status").text("blocked")) - .append(Element::new("reason").text(&reason)) + let feedback_msg = Element::new("important") + .text( + "A post-tool-use hook has flagged the following. \ + You MUST acknowledge this in your next response.", + ) + .append( + Element::new("hook_feedback") + .append(Element::new("event").text(event_name.to_string())) + .append(Element::new("tool").text(tool_name)) + .append(Element::new("status").text("blocked")) + .append(Element::new("reason").text(&reason)), + ) .render(); context .messages @@ -576,18 +613,30 @@ impl EventHandle> for UserHookHandl ); // Inject a message to continue the conversation if let Some(context) = conversation.context.as_mut() { - let continue_msg = Element::new("hook_feedback") - .append(Element::new("event").text("Stop")) - .append(Element::new("status").text("continue")) - .append(Element::new("reason").text(&reason)) + let continue_msg = Element::new("important") + .text( + "A Stop hook has requested the conversation to continue. + You MUST acknowledge this and continue working on the task.", + ) + .append( + Element::new("hook_feedback") + .append(Element::new("event").text("Stop")) + .append(Element::new("status").text("continue")) + .append(Element::new("reason").text(&reason)), + ) .render(); context .messages .push(forge_domain::ContextMessage::user(continue_msg, None).into()); } + // Keep stop_hook_active as true so the next Stop invocation + // sends stop_hook_active: true to the hook script, allowing it + // to detect re-entry and avoid infinite loops. + // Signal the orchestrator to continue the conversation + return Err(anyhow::Error::from(StopBlocked(reason))); } - // Reset the stop hook active flag + // Non-blocking: reset the stop hook active flag self.stop_hook_active.store(false, Ordering::SeqCst); Ok(()) @@ -697,6 +746,22 @@ mod tests { assert!(actual.is_empty()); } + #[test] + fn test_find_matching_hooks_empty_matcher_fires_without_subject() { + let groups = vec![make_group(Some(""), &["stop-hook.sh"])]; + let actual = UserHookHandler::::find_matching_hooks(&groups, None); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].command, Some("stop-hook.sh".to_string())); + } + + #[test] + fn test_find_matching_hooks_empty_matcher_fires_with_subject() { + let groups = vec![make_group(Some(""), &["pre-tool.sh"])]; + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].command, Some("pre-tool.sh".to_string())); + } + #[test] fn test_find_matching_hooks_invalid_regex_skipped() { let groups = vec![make_group(Some("[invalid"), &["block.sh"])]; @@ -1281,4 +1346,712 @@ mod tests { let actual = UserHookHandler::::process_results(&results); assert_eq!(actual, Some("primary".to_string())); } + + // ========================================================================= + // Tests: UserPromptSubmit blocking must return Err(PromptSuppressed) + // ========================================================================= + + /// Helper: creates a UserHookHandler with a given infra and UserPromptSubmit config. + fn prompt_submit_handler(infra: I) -> UserHookHandler { + let json = + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + UserHookHandler::new( + infra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test".to_string(), + ) + } + + /// Helper: creates a RequestPayload EventData with the given request_count. + fn request_event( + request_count: usize, + ) -> EventData { + use forge_domain::{Agent, ModelId, ProviderId}; + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + EventData::new( + agent, + ModelId::new("test-model"), + forge_domain::RequestPayload::new(request_count), + ) + } + + /// Helper: creates a Conversation with a context containing one user message. + fn conversation_with_user_msg(msg: &str) -> forge_domain::Conversation { + let mut conv = forge_domain::Conversation::generate(); + let mut ctx = forge_domain::Context::default(); + ctx.messages + .push(forge_domain::ContextMessage::user(msg.to_string(), None).into()); + conv.context = Some(ctx); + conv + } + + #[tokio::test] + async fn test_user_prompt_submit_block_exit2_returns_error() { + // TC16: exit code 2 must return PromptSuppressed error. + #[derive(Clone)] + struct BlockExit2; + + #[async_trait::async_trait] + impl HookCommandService for BlockExit2 { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "policy violation".to_string(), + }) + } + } + + let handler = prompt_submit_handler(BlockExit2); + let mut event = request_event(0); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.downcast_ref::().is_some()); + assert!(err.to_string().contains("policy violation")); + + // Feedback should have been injected into conversation + let ctx = conversation.context.as_ref().unwrap(); + let last_msg = ctx.messages.last().unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("")); + assert!(content.contains("policy violation")); + } + + #[tokio::test] + async fn test_user_prompt_submit_block_json_decision_returns_error() { + // JSON {"decision":"block","reason":"Content policy"} must block. + #[derive(Clone)] + struct JsonBlockInfra; + + #[async_trait::async_trait] + impl HookCommandService for JsonBlockInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: r#"{"decision":"block","reason":"Content policy"}"#.to_string(), + stderr: String::new(), + }) + } + } + + let handler = prompt_submit_handler(JsonBlockInfra); + let mut event = request_event(0); + let mut conversation = conversation_with_user_msg("test"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.downcast_ref::().is_some()); + assert!(err.to_string().contains("Content policy")); + } + + #[tokio::test] + async fn test_user_prompt_submit_block_continue_false_returns_error() { + // {"continue":false,"reason":"Blocked by admin"} must block. + #[derive(Clone)] + struct ContinueFalseInfra; + + #[async_trait::async_trait] + impl HookCommandService for ContinueFalseInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: r#"{"continue":false,"reason":"Blocked by admin"}"#.to_string(), + stderr: String::new(), + }) + } + } + + let handler = prompt_submit_handler(ContinueFalseInfra); + let mut event = request_event(0); + let mut conversation = conversation_with_user_msg("test"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .downcast_ref::() + .is_some()); + } + + #[tokio::test] + async fn test_user_prompt_submit_allow_returns_ok() { + // Exit 0 + empty stdout => allow, no feedback injected. + let handler = prompt_submit_handler(NullInfra); + let mut event = request_event(0); + let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count); + } + + #[tokio::test] + async fn test_user_prompt_submit_non_blocking_error_returns_ok() { + // Exit code 1 is a non-blocking error — must NOT block. + #[derive(Clone)] + struct Exit1Infra; + + #[async_trait::async_trait] + impl HookCommandService for Exit1Infra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(1), + stdout: String::new(), + stderr: "some error".to_string(), + }) + } + } + + let handler = prompt_submit_handler(Exit1Infra); + let mut event = request_event(0); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_user_prompt_submit_skipped_on_subsequent_requests() { + // request_count > 0 means it's a retry, not a user prompt. + let handler = prompt_submit_handler(NullInfra); + let mut event = request_event(1); // subsequent request + let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count); + } + + // ========================================================================= + // BUG-2 Tests: Stop hook blocking must return Err(StopBlocked) + // ========================================================================= + + /// Helper: creates a UserHookHandler with Stop config and a given infra. + fn stop_handler(infra: I) -> UserHookHandler { + let json = r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + UserHookHandler::new( + infra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test".to_string(), + ) + } + + /// Helper: creates an EndPayload EventData. + fn end_event() -> EventData { + use forge_domain::{Agent, ModelId, ProviderId}; + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + EventData::new(agent, ModelId::new("test-model"), forge_domain::EndPayload) + } + + #[tokio::test] + async fn test_stop_hook_block_returns_stop_blocked_error() { + #[derive(Clone)] + struct StopBlockInfra; + + #[async_trait::async_trait] + impl HookCommandService for StopBlockInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "keep working".to_string(), + }) + } + } + + let handler = stop_handler(StopBlockInfra); + let mut event = end_event(); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.downcast_ref::().is_some()); + assert!(err.to_string().contains("keep working")); + + // Continue message should be injected + let ctx = conversation.context.as_ref().unwrap(); + let last_msg = ctx.messages.last().unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("")); + assert!(content.contains("continue")); + } + + #[tokio::test] + async fn test_stop_hook_allow_returns_ok() { + let handler = stop_handler(NullInfra); + let mut event = end_event(); + let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + // No continue message should be injected + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count); + } + + #[tokio::test] + async fn test_stop_hook_active_guard_prevents_reentry() { + #[derive(Clone)] + struct StopBlockInfra2; + + #[async_trait::async_trait] + impl HookCommandService for StopBlockInfra2 { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "keep working".to_string(), + }) + } + } + + let handler = stop_handler(StopBlockInfra2); + + // Simulate stop_hook_active already being true (re-entrant) + handler + .stop_hook_active + .store(true, std::sync::atomic::Ordering::SeqCst); + + let mut event = end_event(); + let mut conversation = conversation_with_user_msg("hello"); + + // Second call should be a no-op (guard prevents re-entry) + let result = handler.handle(&mut event, &mut conversation).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_stop_hook_active_flag_reset_after_completion() { + let handler = stop_handler(NullInfra); + let mut event = end_event(); + let mut conversation = conversation_with_user_msg("hello"); + + // After a successful (non-blocking) call, flag should be reset + handler.handle(&mut event, &mut conversation).await.unwrap(); + let actual = handler + .stop_hook_active + .load(std::sync::atomic::Ordering::SeqCst); + assert!(!actual); + } + + #[tokio::test] + async fn test_stop_hook_block_json_continue_false() { + #[derive(Clone)] + struct StopJsonBlockInfra; + + #[async_trait::async_trait] + impl HookCommandService for StopJsonBlockInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: r#"{"continue":false,"stopReason":"keep working"}"#.to_string(), + stderr: String::new(), + }) + } + } + + let handler = stop_handler(StopJsonBlockInfra); + let mut event = end_event(); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.downcast_ref::().is_some()); + } + + #[tokio::test] + async fn test_session_end_hooks_still_fire_on_block() { + // When Stop blocks, SessionEnd hooks (fired before Stop) should still + // have executed. We verify by configuring both SessionEnd and Stop hooks + // and checking that the handler processes both. + use std::sync::Arc; + use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; + + #[derive(Clone)] + struct CountingInfra { + call_count: Arc, + } + + #[async_trait::async_trait] + impl HookCommandService for CountingInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + // Return blocking for Stop hooks (exit 2) + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "blocked".to_string(), + }) + } + } + + // Config with both SessionEnd and Stop hooks + let json = r#"{ + "SessionEnd": [{"hooks": [{"type": "command", "command": "echo session-end"}]}], + "Stop": [{"hooks": [{"type": "command", "command": "echo stop"}]}] + }"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + let call_count = Arc::new(AtomicU32::new(0)); + let handler = UserHookHandler::new( + CountingInfra { call_count: call_count.clone() }, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test".to_string(), + ); + + let mut event = end_event(); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + // Stop hook blocks => StopBlocked error + assert!(result.is_err()); + // Both SessionEnd AND Stop hooks should have been called (2 total) + let actual = call_count.load(AtomicOrdering::SeqCst); + assert_eq!(actual, 2); + } + + // ========================================================================= + // BUG-3 Tests: PostToolUse feedback must use wrapper + // ========================================================================= + + /// Helper: creates a UserHookHandler with PostToolUse config and given infra. + fn post_tool_use_handler(infra: I) -> UserHookHandler { + let json = + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + UserHookHandler::new( + infra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test".to_string(), + ) + } + + /// Helper: creates a ToolcallEndPayload EventData with a successful tool result. + fn toolcall_end_event( + tool_name: &str, + is_error: bool, + ) -> EventData { + use forge_domain::{Agent, ModelId, ProviderId, ToolCallFull, ToolResult}; + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + let tool_call = ToolCallFull::new(tool_name); + let result = if is_error { + ToolResult::new(tool_name).failure(anyhow::anyhow!("tool failed")) + } else { + ToolResult::new(tool_name).success("output data") + }; + EventData::new( + agent, + ModelId::new("test-model"), + forge_domain::ToolcallEndPayload::new(tool_call, result), + ) + } + + #[tokio::test] + async fn test_post_tool_use_block_injects_important_feedback() { + #[derive(Clone)] + struct PostToolBlockInfra; + + #[async_trait::async_trait] + impl HookCommandService for PostToolBlockInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "sensitive data detected".to_string(), + }) + } + } + + let handler = post_tool_use_handler(PostToolBlockInfra); + let mut event = toolcall_end_event("shell", false); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + // PostToolUse does NOT block execution — always Ok + assert!(result.is_ok()); + + // But feedback should be injected + let ctx = conversation.context.as_ref().unwrap(); + let last_msg = ctx.messages.last().unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("")); + assert!(content.contains("sensitive data detected")); + } + + #[tokio::test] + async fn test_post_tool_use_block_json_injects_feedback() { + #[derive(Clone)] + struct PostToolJsonBlockInfra; + + #[async_trait::async_trait] + impl HookCommandService for PostToolJsonBlockInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: r#"{"decision":"block","reason":"PII detected"}"#.to_string(), + stderr: String::new(), + }) + } + } + + let handler = post_tool_use_handler(PostToolJsonBlockInfra); + let mut event = toolcall_end_event("shell", false); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + let ctx = conversation.context.as_ref().unwrap(); + let last_msg = ctx.messages.last().unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("PII detected")); + } + + #[tokio::test] + async fn test_post_tool_use_allow_no_feedback() { + let handler = post_tool_use_handler(NullInfra); + let mut event = toolcall_end_event("shell", false); + let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count); + } + + #[tokio::test] + async fn test_post_tool_use_non_blocking_error_no_feedback() { + #[derive(Clone)] + struct Exit1PostInfra; + + #[async_trait::async_trait] + impl HookCommandService for Exit1PostInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(1), + stdout: String::new(), + stderr: "non-blocking error".to_string(), + }) + } + } + + let handler = post_tool_use_handler(Exit1PostInfra); + let mut event = toolcall_end_event("shell", false); + let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count); + } + + #[tokio::test] + async fn test_post_tool_use_failure_event_fires_separately() { + // PostToolUseFailure is a separate event from PostToolUse. + // Configure only PostToolUseFailure hooks and fire with is_error=true. + #[derive(Clone)] + struct FailureBlockInfra; + + #[async_trait::async_trait] + impl HookCommandService for FailureBlockInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "error flagged".to_string(), + }) + } + } + + let json = r#"{"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + let handler = UserHookHandler::new( + FailureBlockInfra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test".to_string(), + ); + + let mut event = toolcall_end_event("shell", true); + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + let ctx = conversation.context.as_ref().unwrap(); + let last_msg = ctx.messages.last().unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("error flagged")); + } + + #[tokio::test] + async fn test_post_tool_use_feedback_contains_tool_name() { + #[derive(Clone)] + struct PostToolBlockInfra2; + + #[async_trait::async_trait] + impl HookCommandService for PostToolBlockInfra2 { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(2), + stdout: String::new(), + stderr: "flagged".to_string(), + }) + } + } + + let handler = post_tool_use_handler(PostToolBlockInfra2); + let mut event = toolcall_end_event("shell", false); + let mut conversation = conversation_with_user_msg("hello"); + + handler + .handle(&mut event, &mut conversation) + .await + .unwrap(); + + let ctx = conversation.context.as_ref().unwrap(); + let last_msg = ctx.messages.last().unwrap(); + let content = last_msg.content().unwrap(); + // The feedback should reference the tool name + assert!(content.contains("shell")); + } } diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index 5098d66577..99cb941cb5 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_recursion::async_recursion; use derive_setters::Setters; use forge_config::RetryConfig; -use forge_domain::{Agent, *}; +use forge_domain::{Agent, PromptSuppressed, StopBlocked, *}; use forge_template::Element; use tokio::sync::Notify; use tracing::warn; @@ -232,6 +232,11 @@ impl Orchestrator { let mut request_count = 0; + // Tracks whether the End lifecycle event has already been fired + // inside the loop (e.g. to give the Stop hook a chance to force + // continuation). When true, the post-loop End event is skipped. + let mut end_event_fired = false; + // Retrieve the number of requests allowed per tick. let max_requests_per_turn = self.agent.max_requests_per_turn; let tool_context = @@ -242,15 +247,29 @@ impl Orchestrator { self.conversation.context = Some(context.clone()); self.services.update(self.conversation.clone()).await?; - // Fire the Request lifecycle event + // Fire the Request lifecycle event. + // A UserPromptSubmit hook may suppress the prompt by returning + // PromptSuppressed. In that case, we exit the loop cleanly + // without making the LLM call. let mut request_event = LifecycleEvent::Request(EventData::new( self.agent.clone(), model_id.clone(), RequestPayload::new(request_count), )); - self.hook + if let Err(e) = self + .hook .handle(&mut request_event, &mut self.conversation) - .await?; + .await + { + if e.downcast_ref::().is_some() { + // Prompt was blocked by a UserPromptSubmit hook. + // Persist the conversation (which now contains the feedback + // message) and exit cleanly. + self.services.update(self.conversation.clone()).await?; + break; + } + return Err(e); + } let message = crate::retry::retry_with_config( &self.retry_config, @@ -387,19 +406,57 @@ impl Orchestrator { tool_context.with_metrics(|metrics| { self.conversation.metrics = metrics.clone(); })?; + + // If the agent is about to stop (task complete), fire the End + // event inside the loop so a Stop hook can force continuation. + if should_yield && is_complete { + let end_result = self + .hook + .handle( + &mut LifecycleEvent::End(EventData::new( + self.agent.clone(), + model_id.clone(), + EndPayload, + )), + &mut self.conversation, + ) + .await; + match end_result { + Err(e) if e.downcast_ref::().is_some() => { + // Stop hook wants to continue — re-enter the loop. + // Update context from conversation (handler may have + // injected a continue message). + if let Some(updated_context) = &self.conversation.context { + context = updated_context.clone(); + } + should_yield = false; + is_complete = false; + end_event_fired = true; + continue; + } + Err(e) => return Err(e), + Ok(()) => { + end_event_fired = true; + } + } + } } - // Fire the End lifecycle event (title will be set here by the hook) - self.hook - .handle( - &mut LifecycleEvent::End(EventData::new( - self.agent.clone(), - model_id.clone(), - EndPayload, - )), - &mut self.conversation, - ) - .await?; + // Fire the End lifecycle event if it wasn't already fired inside the + // loop (e.g. when yielding due to a tool requesting follow-up, or + // when the Stop hook did not block). + if !end_event_fired { + self.hook + .handle( + &mut LifecycleEvent::End(EventData::new( + self.agent.clone(), + model_id.clone(), + EndPayload, + )), + &mut self.conversation, + ) + .await?; + } self.services.update(self.conversation.clone()).await?; diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index dd0f02dde2..fb729695c7 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -398,6 +398,38 @@ where } } +/// Error indicating a UserPromptSubmit hook blocked the prompt. +/// +/// When a UserPromptSubmit hook exits with code 2 or returns a blocking JSON +/// decision, the handler returns this error to signal the orchestrator that +/// the prompt should be suppressed (the LLM call must not proceed). +#[derive(Debug)] +pub struct PromptSuppressed(pub String); + +impl std::fmt::Display for PromptSuppressed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Prompt suppressed by hook: {}", self.0) + } +} + +impl std::error::Error for PromptSuppressed {} + +/// Error indicating a Stop hook blocked the agent from stopping. +/// +/// When a Stop hook exits with code 2 or returns a blocking JSON decision, +/// the handler returns this error to signal the orchestrator that the agent +/// should continue working instead of stopping. +#[derive(Debug)] +pub struct StopBlocked(pub String); + +impl std::fmt::Display for StopBlocked { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Stop blocked by hook: {}", self.0) + } +} + +impl std::error::Error for StopBlocked {} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs index f542edf130..d97739e71e 100644 --- a/crates/forge_domain/src/user_hook_config.rs +++ b/crates/forge_domain/src/user_hook_config.rs @@ -77,6 +77,14 @@ pub enum UserHookEventName { /// FIXME: Fired after context compaction; no lifecycle point fires this /// event and no handler exists yet. PostCompact, + /// FIXME: Fired when a subagent starts; no lifecycle point fires this + SubagentStart, + /// FIXME: Fired when a subagent stops; no lifecycle point fires this + SubagentStop, + /// FIXME: no lifecycle point fires this + PermissionRequest, + /// FIXME: no lifecycle point fires this + Setup, } /// A matcher group pairs an optional regex matcher with a list of hook @@ -84,8 +92,8 @@ pub enum UserHookEventName { /// /// When a lifecycle event fires, only matcher groups whose `matcher` regex /// matches the relevant event context (e.g., tool name) will have their hooks -/// executed. If `matcher` is `None`, all hooks in this group fire -/// unconditionally. +/// executed. If `matcher` is `None` (or an empty string, which is normalized +/// to `None`), all hooks in this group fire unconditionally. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct UserHookMatcherGroup { /// Optional regex pattern to match against (e.g., tool name for diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 8e2c39d4c9..19e2ebc67b 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -40,6 +40,9 @@ pub enum HookEventInput { tool_name: String, /// Tool call arguments as a JSON value. tool_input: Value, + /// Unique identifier for this tool call. + #[serde(default, skip_serializing_if = "Option::is_none")] + tool_use_id: Option, }, /// Input for PostToolUse events. PostToolUse { @@ -49,6 +52,9 @@ pub enum HookEventInput { tool_input: Value, /// Tool output/response as a JSON value. tool_response: Value, + /// Unique identifier for this tool call. + #[serde(default, skip_serializing_if = "Option::is_none")] + tool_use_id: Option, }, /// Input for Stop events. Stop { @@ -215,6 +221,7 @@ mod tests { event_data: HookEventInput::PreToolUse { tool_name: "Bash".to_string(), tool_input: serde_json::json!({"command": "ls"}), + tool_use_id: None, }, }; @@ -224,6 +231,25 @@ mod tests { assert_eq!(actual["cwd"], "/project"); assert_eq!(actual["tool_name"], "Bash"); assert_eq!(actual["tool_input"]["command"], "ls"); + assert!(actual.get("tool_use_id").is_none()); + } + + #[test] + fn test_hook_input_serialization_pre_tool_use_with_tool_use_id() { + let fixture = HookInput { + hook_event_name: "PreToolUse".to_string(), + cwd: "/project".to_string(), + session_id: Some("sess-123".to_string()), + event_data: HookEventInput::PreToolUse { + tool_name: "Bash".to_string(), + tool_input: serde_json::json!({"command": "ls"}), + tool_use_id: Some("forge_call_id_abc123".to_string()), + }, + }; + + let actual = serde_json::to_value(&fixture).unwrap(); + + assert_eq!(actual["tool_use_id"], "forge_call_id_abc123"); } #[test] From 1ccb95a07af30058385fddf5c9f5af41824cf996 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:00:29 -0400 Subject: [PATCH 38/64] feat(hooks): include last assistant message in stop hook input --- .../forge_app/src/hooks/user_hook_handler.rs | 15 ++++++++++- crates/forge_domain/src/user_hook_io.rs | 27 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 2ea76c5e46..3183f8c080 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -597,11 +597,24 @@ impl EventHandle> for UserHookHandl return Ok(()); } + // Extract the last assistant message text for the Stop hook payload. + let last_assistant_message = conversation + .context + .as_ref() + .and_then(|ctx| { + ctx.messages + .iter() + .rev() + .find(|m| m.has_role(Role::Assistant)) + .and_then(|m| m.content()) + .map(|s| s.to_string()) + }); + let input = HookInput { hook_event_name: "Stop".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::Stop { stop_hook_active: was_active }, + event_data: HookEventInput::Stop { stop_hook_active: was_active, last_assistant_message }, }; let results = self.execute_hooks(&hooks, &input).await; diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 19e2ebc67b..ff5fb428b4 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -60,6 +60,9 @@ pub enum HookEventInput { Stop { /// Whether a Stop hook has already fired (prevents infinite loops). stop_hook_active: bool, + /// The last assistant message text before the stop event. + #[serde(default, skip_serializing_if = "Option::is_none")] + last_assistant_message: Option, }, /// Input for SessionStart events. SessionStart { @@ -258,13 +261,35 @@ mod tests { hook_event_name: "Stop".to_string(), cwd: "/project".to_string(), session_id: None, - event_data: HookEventInput::Stop { stop_hook_active: false }, + event_data: HookEventInput::Stop { + stop_hook_active: false, + last_assistant_message: None, + }, }; let actual = serde_json::to_value(&fixture).unwrap(); assert_eq!(actual["hook_event_name"], "Stop"); assert_eq!(actual["stop_hook_active"], false); + assert!(actual.get("last_assistant_message").is_none()); + } + + #[test] + fn test_hook_input_serialization_stop_with_last_assistant_message() { + let fixture = HookInput { + hook_event_name: "Stop".to_string(), + cwd: "/project".to_string(), + session_id: Some("sess-456".to_string()), + event_data: HookEventInput::Stop { + stop_hook_active: true, + last_assistant_message: Some("Here is the result.".to_string()), + }, + }; + + let actual = serde_json::to_value(&fixture).unwrap(); + + assert_eq!(actual["stop_hook_active"], true); + assert_eq!(actual["last_assistant_message"], "Here is the result."); } #[test] From 924b5c919a88129c9a2f43b858b9faed6d624266 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:59:19 -0400 Subject: [PATCH 39/64] refactor(hooks): accept cwd as Path in user hook executor --- crates/forge_app/src/hooks/user_hook_executor.rs | 6 +++--- crates/forge_services/src/user_hook_config.rs | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index 225d7dbce9..cf8ffcbdcc 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::Path; use std::time::Duration; use forge_domain::{CommandOutput, HookExecutionResult}; @@ -44,7 +44,7 @@ impl UserHookExecutor { command: &str, input_json: &str, timeout_duration: Duration, - cwd: &PathBuf, + cwd: &Path, env_vars: &HashMap, ) -> anyhow::Result { debug!( @@ -58,7 +58,7 @@ impl UserHookExecutor { timeout_duration, self.0.execute_command_with_input( command.to_string(), - cwd.clone(), + cwd.to_path_buf(), input_json.to_string(), env_vars.clone(), ), diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index 22b8c665be..d349a3a94b 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -349,6 +349,10 @@ mod tests { env } + fn get_config(&self) -> anyhow::Result { + unimplemented!("not needed for tests") + } + async fn update_environment( &self, _ops: Vec, From 06e78e4890481c374f6cd373a0ea15d70672d2c1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:00:59 +0000 Subject: [PATCH 40/64] [autofix.ci] apply automated fixes --- .../forge_app/src/hooks/user_hook_handler.rs | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 3183f8c080..db03fee269 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -6,9 +6,9 @@ use std::time::Duration; use async_trait::async_trait; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, - HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, - ResponsePayload, Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, - ToolcallStartPayload, UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, + HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, ResponsePayload, + Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, + UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, }; use forge_template::Element; use regex::Regex; @@ -598,23 +598,23 @@ impl EventHandle> for UserHookHandl } // Extract the last assistant message text for the Stop hook payload. - let last_assistant_message = conversation - .context - .as_ref() - .and_then(|ctx| { - ctx.messages - .iter() - .rev() - .find(|m| m.has_role(Role::Assistant)) - .and_then(|m| m.content()) - .map(|s| s.to_string()) - }); + let last_assistant_message = conversation.context.as_ref().and_then(|ctx| { + ctx.messages + .iter() + .rev() + .find(|m| m.has_role(Role::Assistant)) + .and_then(|m| m.content()) + .map(|s| s.to_string()) + }); let input = HookInput { hook_event_name: "Stop".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::Stop { stop_hook_active: was_active, last_assistant_message }, + event_data: HookEventInput::Stop { + stop_hook_active: was_active, + last_assistant_message, + }, }; let results = self.execute_hooks(&hooks, &input).await; @@ -1364,7 +1364,8 @@ mod tests { // Tests: UserPromptSubmit blocking must return Err(PromptSuppressed) // ========================================================================= - /// Helper: creates a UserHookHandler with a given infra and UserPromptSubmit config. + /// Helper: creates a UserHookHandler with a given infra and + /// UserPromptSubmit config. fn prompt_submit_handler(infra: I) -> UserHookHandler { let json = r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; @@ -1379,9 +1380,7 @@ mod tests { } /// Helper: creates a RequestPayload EventData with the given request_count. - fn request_event( - request_count: usize, - ) -> EventData { + fn request_event(request_count: usize) -> EventData { use forge_domain::{Agent, ModelId, ProviderId}; let agent = Agent::new( "test-agent", @@ -1395,7 +1394,8 @@ mod tests { ) } - /// Helper: creates a Conversation with a context containing one user message. + /// Helper: creates a Conversation with a context containing one user + /// message. fn conversation_with_user_msg(msg: &str) -> forge_domain::Conversation { let mut conv = forge_domain::Conversation::generate(); let mut ctx = forge_domain::Context::default(); @@ -1437,7 +1437,10 @@ mod tests { assert!(result.is_err()); let err = result.unwrap_err(); - assert!(err.downcast_ref::().is_some()); + assert!( + err.downcast_ref::() + .is_some() + ); assert!(err.to_string().contains("policy violation")); // Feedback should have been injected into conversation @@ -1480,7 +1483,10 @@ mod tests { assert!(result.is_err()); let err = result.unwrap_err(); - assert!(err.downcast_ref::().is_some()); + assert!( + err.downcast_ref::() + .is_some() + ); assert!(err.to_string().contains("Content policy")); } @@ -1515,10 +1521,12 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; assert!(result.is_err()); - assert!(result - .unwrap_err() - .downcast_ref::() - .is_some()); + assert!( + result + .unwrap_err() + .downcast_ref::() + .is_some() + ); } #[tokio::test] @@ -1819,10 +1827,10 @@ mod tests { // BUG-3 Tests: PostToolUse feedback must use wrapper // ========================================================================= - /// Helper: creates a UserHookHandler with PostToolUse config and given infra. + /// Helper: creates a UserHookHandler with PostToolUse config and given + /// infra. fn post_tool_use_handler(infra: I) -> UserHookHandler { - let json = - r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let json = r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); UserHookHandler::new( infra, @@ -1833,7 +1841,8 @@ mod tests { ) } - /// Helper: creates a ToolcallEndPayload EventData with a successful tool result. + /// Helper: creates a ToolcallEndPayload EventData with a successful tool + /// result. fn toolcall_end_event( tool_name: &str, is_error: bool, @@ -2007,7 +2016,8 @@ mod tests { } } - let json = r#"{"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let json = + r#"{"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); let handler = UserHookHandler::new( FailureBlockInfra, @@ -2056,10 +2066,7 @@ mod tests { let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); - handler - .handle(&mut event, &mut conversation) - .await - .unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let ctx = conversation.context.as_ref().unwrap(); let last_msg = ctx.messages.last().unwrap(); From 4b276aa80aa0399a5335c2dd56ab03b46c3a203f Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:37:33 -0400 Subject: [PATCH 41/64] feat(hooks): add user hook config schema and toml fixtures --- .../src/fixtures/hook_multiple_events.toml | 18 ++ .../src/fixtures/hook_no_matcher.toml | 5 + .../src/fixtures/hook_pre_tool_use.toml | 6 + .../src/fixtures/hook_with_timeout.toml | 6 + crates/forge_config/src/hooks.rs | 258 ++++++++++++++++++ 5 files changed, 293 insertions(+) create mode 100644 crates/forge_config/src/fixtures/hook_multiple_events.toml create mode 100644 crates/forge_config/src/fixtures/hook_no_matcher.toml create mode 100644 crates/forge_config/src/fixtures/hook_pre_tool_use.toml create mode 100644 crates/forge_config/src/fixtures/hook_with_timeout.toml create mode 100644 crates/forge_config/src/hooks.rs diff --git a/crates/forge_config/src/fixtures/hook_multiple_events.toml b/crates/forge_config/src/fixtures/hook_multiple_events.toml new file mode 100644 index 0000000000..832fb8cb41 --- /dev/null +++ b/crates/forge_config/src/fixtures/hook_multiple_events.toml @@ -0,0 +1,18 @@ +[[PreToolUse]] +matcher = "Bash" + + [[PreToolUse.hooks]] + type = "command" + command = "pre.sh" + +[[PostToolUse]] + + [[PostToolUse.hooks]] + type = "command" + command = "post.sh" + +[[Stop]] + + [[Stop.hooks]] + type = "command" + command = "stop.sh" diff --git a/crates/forge_config/src/fixtures/hook_no_matcher.toml b/crates/forge_config/src/fixtures/hook_no_matcher.toml new file mode 100644 index 0000000000..1bf222177b --- /dev/null +++ b/crates/forge_config/src/fixtures/hook_no_matcher.toml @@ -0,0 +1,5 @@ +[[PostToolUse]] + + [[PostToolUse.hooks]] + type = "command" + command = "always.sh" diff --git a/crates/forge_config/src/fixtures/hook_pre_tool_use.toml b/crates/forge_config/src/fixtures/hook_pre_tool_use.toml new file mode 100644 index 0000000000..159f1ddbe4 --- /dev/null +++ b/crates/forge_config/src/fixtures/hook_pre_tool_use.toml @@ -0,0 +1,6 @@ +[[PreToolUse]] +matcher = "Bash" + + [[PreToolUse.hooks]] + type = "command" + command = "echo 'blocked'" diff --git a/crates/forge_config/src/fixtures/hook_with_timeout.toml b/crates/forge_config/src/fixtures/hook_with_timeout.toml new file mode 100644 index 0000000000..b5f5f5083b --- /dev/null +++ b/crates/forge_config/src/fixtures/hook_with_timeout.toml @@ -0,0 +1,6 @@ +[[PreToolUse]] + + [[PreToolUse.hooks]] + type = "command" + command = "slow.sh" + timeout = 30000 diff --git a/crates/forge_config/src/hooks.rs b/crates/forge_config/src/hooks.rs new file mode 100644 index 0000000000..aa61e01541 --- /dev/null +++ b/crates/forge_config/src/hooks.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; + +use fake::Dummy; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use strum_macros::Display; + +/// Top-level user hook configuration. +/// +/// Maps hook event names to a list of matcher groups. This is deserialized +/// from the `hooks` section in `.forge.toml`. +/// +/// Example TOML: +/// ```toml +/// [[hooks.PreToolUse]] +/// matcher = "Bash" +/// +/// [[hooks.PreToolUse.hooks]] +/// type = "command" +/// command = "echo hi" +/// ``` +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, Dummy)] +pub struct UserHookConfig { + /// Map of event name -> list of matcher groups. + #[serde(flatten)] + pub events: HashMap>, +} + +impl UserHookConfig { + /// Creates an empty user hook configuration. + pub fn new() -> Self { + Self { events: HashMap::new() } + } + + /// Returns the matcher groups for a given event name, or an empty slice if + /// none. + pub fn get_groups(&self, event: &UserHookEventName) -> &[UserHookMatcherGroup] { + self.events.get(event).map_or(&[], |v| v.as_slice()) + } + + /// Returns true if no hook events are configured. + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Supported hook event names that map to lifecycle points in the +/// orchestrator. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, JsonSchema, Dummy, +)] +pub enum UserHookEventName { + /// Fired before a tool call executes. Can block execution. + PreToolUse, + /// Fired after a tool call succeeds. + PostToolUse, + /// Fired after a tool call fails. + PostToolUseFailure, + /// Fired when the agent finishes responding. Can block stop to continue. + Stop, + /// Fired when a notification is sent. + Notification, + /// Fired when a session starts or resumes. + SessionStart, + /// Fired when a session ends/terminates. + SessionEnd, + /// Fired when a user prompt is submitted. + UserPromptSubmit, + /// Fired before context compaction. + PreCompact, + /// Fired after context compaction. + PostCompact, + /// Fired when a subagent starts. + SubagentStart, + /// Fired when a subagent stops. + SubagentStop, + /// Fired when a permission is requested. + PermissionRequest, + /// Fired during setup. + Setup, +} + +/// A matcher group pairs an optional regex matcher with a list of hook +/// handlers. +/// +/// When a lifecycle event fires, only matcher groups whose `matcher` regex +/// matches the relevant event context (e.g., tool name) will have their hooks +/// executed. If `matcher` is `None` (or an empty string, which is normalized +/// to `None`), all hooks in this group fire unconditionally. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, Dummy)] +pub struct UserHookMatcherGroup { + /// Optional regex pattern to match against (e.g., tool name for + /// PreToolUse/PostToolUse). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + + /// List of hook handlers to execute when this matcher matches. + #[serde(default)] + pub hooks: Vec, +} + +/// A single hook handler entry that defines what action to take. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, Dummy)] +pub struct UserHookEntry { + /// The type of hook handler. + #[serde(rename = "type")] + pub hook_type: UserHookType, + + /// The shell command to execute (for `Command` type hooks). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + + /// Timeout in milliseconds for this hook. Defaults to 600000ms (10 + /// minutes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// The type of hook handler to execute. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, Dummy)] +#[serde(rename_all = "lowercase")] +pub enum UserHookType { + /// Executes a shell command, piping JSON to stdin and reading JSON from + /// stdout. + Command, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_deserialize_empty_config() { + let toml = ""; + let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); + let expected = UserHookConfig::new(); + assert_eq!(actual, expected); + } + + #[test] + fn test_deserialize_pre_tool_use_hook() { + let toml = r#" +[[PreToolUse]] +matcher = "Bash" + + [[PreToolUse.hooks]] + type = "command" + command = "echo 'blocked'" +"#; + let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); + let groups = actual.get_groups(&UserHookEventName::PreToolUse); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].matcher, Some("Bash".to_string())); + assert_eq!(groups[0].hooks.len(), 1); + assert_eq!(groups[0].hooks[0].hook_type, UserHookType::Command); + assert_eq!( + groups[0].hooks[0].command, + Some("echo 'blocked'".to_string()) + ); + } + + #[test] + fn test_deserialize_multiple_events() { + let toml = r#" +[[PreToolUse]] +matcher = "Bash" + + [[PreToolUse.hooks]] + type = "command" + command = "pre.sh" + +[[PostToolUse]] + + [[PostToolUse.hooks]] + type = "command" + command = "post.sh" + +[[Stop]] + + [[Stop.hooks]] + type = "command" + command = "stop.sh" +"#; + let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); + + assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 1); + assert_eq!(actual.get_groups(&UserHookEventName::PostToolUse).len(), 1); + assert_eq!(actual.get_groups(&UserHookEventName::Stop).len(), 1); + assert!( + actual + .get_groups(&UserHookEventName::SessionStart) + .is_empty() + ); + } + + #[test] + fn test_deserialize_hook_with_timeout() { + let toml = r#" +[[PreToolUse]] + + [[PreToolUse.hooks]] + type = "command" + command = "slow.sh" + timeout = 30000 +"#; + let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); + let groups = actual.get_groups(&UserHookEventName::PreToolUse); + + assert_eq!(groups[0].hooks[0].timeout, Some(30000)); + } + + #[test] + fn test_no_matcher_group_fires_unconditionally() { + let toml = r#" +[[PostToolUse]] + + [[PostToolUse.hooks]] + type = "command" + command = "always.sh" +"#; + let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); + let groups = actual.get_groups(&UserHookEventName::PostToolUse); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].matcher, None); + } + + #[test] + fn test_toml_round_trip() { + let toml_input = r#" +[[PreToolUse]] +matcher = "Bash" + + [[PreToolUse.hooks]] + type = "command" + command = "check.sh" + timeout = 5000 +"#; + let config: UserHookConfig = toml_edit::de::from_str(toml_input).unwrap(); + let serialized = toml_edit::ser::to_string_pretty(&config).unwrap(); + let roundtrip: UserHookConfig = toml_edit::de::from_str(&serialized).unwrap(); + assert_eq!(config, roundtrip); + } + + #[test] + fn test_json_deserialization_still_works() { + let json = r#"{ + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo hi" }] } + ] + }"#; + let actual: UserHookConfig = serde_json::from_str(json).unwrap(); + assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 1); + } +} From d8c47647c4c682b605d9ca3b98d72f182be17f80 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:37:37 -0400 Subject: [PATCH 42/64] feat(hooks): load user hook config from .forge.toml via forge_config --- .../forge_app/src/hooks/user_hook_handler.rs | 15 +- crates/forge_app/src/services.rs | 12 +- crates/forge_config/src/config.rs | 42 +++ crates/forge_config/src/hooks.rs | 64 +--- crates/forge_config/src/lib.rs | 2 + .../src/fixtures/hook_merge_config_1.json | 5 - .../src/fixtures/hook_merge_config_2.json | 8 - .../src/fixtures/hook_multiple_events.json | 11 - .../src/fixtures/hook_no_matcher.json | 5 - .../src/fixtures/hook_pre_tool_use.json | 13 - .../fixtures/hook_settings_with_hooks.json | 7 - .../src/fixtures/hook_with_timeout.json | 9 - crates/forge_domain/src/lib.rs | 2 - crates/forge_domain/src/user_hook_config.rs | 250 -------------- crates/forge_services/src/user_hook_config.rs | 316 ++---------------- forge.schema.json | 81 +++++ 16 files changed, 182 insertions(+), 660 deletions(-) delete mode 100644 crates/forge_domain/src/fixtures/hook_merge_config_1.json delete mode 100644 crates/forge_domain/src/fixtures/hook_merge_config_2.json delete mode 100644 crates/forge_domain/src/fixtures/hook_multiple_events.json delete mode 100644 crates/forge_domain/src/fixtures/hook_no_matcher.json delete mode 100644 crates/forge_domain/src/fixtures/hook_pre_tool_use.json delete mode 100644 crates/forge_domain/src/fixtures/hook_settings_with_hooks.json delete mode 100644 crates/forge_domain/src/fixtures/hook_with_timeout.json delete mode 100644 crates/forge_domain/src/user_hook_config.rs diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index db03fee269..8a2612ebac 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -4,11 +4,14 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use async_trait::async_trait; +use forge_config::{ + UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, +}; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, - HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, ResponsePayload, - Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, - UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, + HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, + ResponsePayload, Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, + ToolcallStartPayload, }; use forge_template::Element; use regex::Regex; @@ -661,10 +664,10 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; - use forge_domain::{ - CommandOutput, HookExecutionResult, UserHookEntry, UserHookEventName, UserHookMatcherGroup, - UserHookType, + use forge_config::{ + UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType, }; + use forge_domain::{CommandOutput, HookExecutionResult}; use pretty_assertions::assert_eq; use super::*; diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 669ea12770..03cb999b77 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -487,14 +487,8 @@ pub trait CommandLoaderService: Send + Sync { #[async_trait::async_trait] pub trait UserHookConfigService: Send + Sync { - /// Loads and merges user hook configurations from all settings file - /// locations. - /// - /// Resolution order (all merged, not overridden): - /// 1. `~/.forge/settings.json` (user-level, applies to all projects) - /// 2. `.forge/settings.json` (project-level, committable) - /// 3. `.forge/settings.local.json` (project-level, gitignored) - async fn get_user_hook_config(&self) -> anyhow::Result; + /// Loads user hook configuration from `.forge.toml`. + async fn get_user_hook_config(&self) -> anyhow::Result; } #[async_trait::async_trait] @@ -983,7 +977,7 @@ impl CommandLoaderService for I { #[async_trait::async_trait] impl UserHookConfigService for I { - async fn get_user_hook_config(&self) -> anyhow::Result { + async fn get_user_hook_config(&self) -> anyhow::Result { self.user_hook_config_service().get_user_hook_config().await } } diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 0f189de0e3..5fd12060a9 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -10,6 +10,7 @@ use crate::reader::ConfigReader; use crate::writer::ConfigWriter; use crate::{ AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, Update, + UserHookConfig, }; /// Wire protocol a provider uses for chat completions. @@ -265,6 +266,13 @@ pub struct ForgeConfig { /// selection. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub providers: Vec, + + /// User hook configuration loaded from the `[hooks]` section. + /// + /// Maps lifecycle event names (e.g. `PreToolUse`, `Stop`) to lists of + /// matcher groups that execute shell commands at each event point. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hooks: Option, } impl ForgeConfig { @@ -337,4 +345,38 @@ mod tests { assert_eq!(actual.temperature, fixture.temperature); } + + #[test] + fn test_hooks_toml_round_trip() { + use crate::{UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType}; + + let mut events = std::collections::HashMap::new(); + events.insert( + UserHookEventName::PreToolUse, + vec![UserHookMatcherGroup { + matcher: Some("Bash".to_string()), + hooks: vec![UserHookEntry { + hook_type: UserHookType::Command, + command: Some("check.sh".to_string()), + timeout: Some(5000), + }], + }], + ); + let fixture = ForgeConfig { + hooks: Some(UserHookConfig { events }), + ..Default::default() + }; + + let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); + let actual: ForgeConfig = toml_edit::de::from_str(&toml).unwrap(); + + assert_eq!(actual.hooks, fixture.hooks); + } + + #[test] + fn test_config_without_hooks_parses() { + let toml = "restricted = false\ntool_supported = true\n"; + let actual: ForgeConfig = toml_edit::de::from_str(toml).unwrap(); + assert_eq!(actual.hooks, None); + } } diff --git a/crates/forge_config/src/hooks.rs b/crates/forge_config/src/hooks.rs index aa61e01541..fde6adcc84 100644 --- a/crates/forge_config/src/hooks.rs +++ b/crates/forge_config/src/hooks.rs @@ -58,7 +58,8 @@ pub enum UserHookEventName { PostToolUseFailure, /// Fired when the agent finishes responding. Can block stop to continue. Stop, - /// Fired when a notification is sent. + /// FIXME: Fired when a notification is sent; no lifecycle point fires this + /// event and no handler exists yet. Notification, /// Fired when a session starts or resumes. SessionStart, @@ -66,17 +67,19 @@ pub enum UserHookEventName { SessionEnd, /// Fired when a user prompt is submitted. UserPromptSubmit, - /// Fired before context compaction. + /// FIXME: Fired before context compaction; no lifecycle point fires this + /// event and no handler exists yet. PreCompact, - /// Fired after context compaction. + /// FIXME: Fired after context compaction; no lifecycle point fires this + /// event and no handler exists yet. PostCompact, - /// Fired when a subagent starts. + /// FIXME: Fired when a subagent starts; no lifecycle point fires this SubagentStart, - /// Fired when a subagent stops. + /// FIXME: Fired when a subagent stops; no lifecycle point fires this SubagentStop, - /// Fired when a permission is requested. + /// FIXME: no lifecycle point fires this PermissionRequest, - /// Fired during setup. + /// FIXME: no lifecycle point fires this Setup, } @@ -141,14 +144,7 @@ mod tests { #[test] fn test_deserialize_pre_tool_use_hook() { - let toml = r#" -[[PreToolUse]] -matcher = "Bash" - - [[PreToolUse.hooks]] - type = "command" - command = "echo 'blocked'" -"#; + let toml = include_str!("fixtures/hook_pre_tool_use.toml"); let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); let groups = actual.get_groups(&UserHookEventName::PreToolUse); @@ -164,26 +160,7 @@ matcher = "Bash" #[test] fn test_deserialize_multiple_events() { - let toml = r#" -[[PreToolUse]] -matcher = "Bash" - - [[PreToolUse.hooks]] - type = "command" - command = "pre.sh" - -[[PostToolUse]] - - [[PostToolUse.hooks]] - type = "command" - command = "post.sh" - -[[Stop]] - - [[Stop.hooks]] - type = "command" - command = "stop.sh" -"#; + let toml = include_str!("fixtures/hook_multiple_events.toml"); let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 1); @@ -198,14 +175,7 @@ matcher = "Bash" #[test] fn test_deserialize_hook_with_timeout() { - let toml = r#" -[[PreToolUse]] - - [[PreToolUse.hooks]] - type = "command" - command = "slow.sh" - timeout = 30000 -"#; + let toml = include_str!("fixtures/hook_with_timeout.toml"); let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); let groups = actual.get_groups(&UserHookEventName::PreToolUse); @@ -214,13 +184,7 @@ matcher = "Bash" #[test] fn test_no_matcher_group_fires_unconditionally() { - let toml = r#" -[[PostToolUse]] - - [[PostToolUse.hooks]] - type = "command" - command = "always.sh" -"#; + let toml = include_str!("fixtures/hook_no_matcher.toml"); let actual: UserHookConfig = toml_edit::de::from_str(toml).unwrap(); let groups = actual.get_groups(&UserHookEventName::PostToolUse); diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index cc253277e4..5d903b1a22 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -3,6 +3,7 @@ mod compact; mod config; mod decimal; mod error; +mod hooks; mod http; mod legacy; mod model; @@ -17,6 +18,7 @@ pub use compact::*; pub use config::*; pub use decimal::*; pub use error::Error; +pub use hooks::*; pub use http::*; pub use model::*; pub use percentage::*; diff --git a/crates/forge_domain/src/fixtures/hook_merge_config_1.json b/crates/forge_domain/src/fixtures/hook_merge_config_1.json deleted file mode 100644 index 2079f740d7..0000000000 --- a/crates/forge_domain/src/fixtures/hook_merge_config_1.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "hook1.sh" }] } - ] -} diff --git a/crates/forge_domain/src/fixtures/hook_merge_config_2.json b/crates/forge_domain/src/fixtures/hook_merge_config_2.json deleted file mode 100644 index b09c020a1c..0000000000 --- a/crates/forge_domain/src/fixtures/hook_merge_config_2.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "PreToolUse": [ - { "matcher": "Write", "hooks": [{ "type": "command", "command": "hook2.sh" }] } - ], - "Stop": [ - { "hooks": [{ "type": "command", "command": "stop.sh" }] } - ] -} diff --git a/crates/forge_domain/src/fixtures/hook_multiple_events.json b/crates/forge_domain/src/fixtures/hook_multiple_events.json deleted file mode 100644 index 01096bcadd..0000000000 --- a/crates/forge_domain/src/fixtures/hook_multiple_events.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "pre.sh" }] } - ], - "PostToolUse": [ - { "hooks": [{ "type": "command", "command": "post.sh" }] } - ], - "Stop": [ - { "hooks": [{ "type": "command", "command": "stop.sh" }] } - ] -} diff --git a/crates/forge_domain/src/fixtures/hook_no_matcher.json b/crates/forge_domain/src/fixtures/hook_no_matcher.json deleted file mode 100644 index 10699df53c..0000000000 --- a/crates/forge_domain/src/fixtures/hook_no_matcher.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "PostToolUse": [ - { "hooks": [{ "type": "command", "command": "always.sh" }] } - ] -} diff --git a/crates/forge_domain/src/fixtures/hook_pre_tool_use.json b/crates/forge_domain/src/fixtures/hook_pre_tool_use.json deleted file mode 100644 index 9d27b21463..0000000000 --- a/crates/forge_domain/src/fixtures/hook_pre_tool_use.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "echo 'blocked'" - } - ] - } - ] -} diff --git a/crates/forge_domain/src/fixtures/hook_settings_with_hooks.json b/crates/forge_domain/src/fixtures/hook_settings_with_hooks.json deleted file mode 100644 index 5b251d1d73..0000000000 --- a/crates/forge_domain/src/fixtures/hook_settings_with_hooks.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } - ] - } -} diff --git a/crates/forge_domain/src/fixtures/hook_with_timeout.json b/crates/forge_domain/src/fixtures/hook_with_timeout.json deleted file mode 100644 index 2c78f8296e..0000000000 --- a/crates/forge_domain/src/fixtures/hook_with_timeout.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "PreToolUse": [ - { - "hooks": [ - { "type": "command", "command": "slow.sh", "timeout": 30000 } - ] - } - ] -} diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index 962e70253e..d97bce49d5 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -50,7 +50,6 @@ mod top_k; mod top_p; mod transformer; mod update; -mod user_hook_config; mod user_hook_io; mod validation; mod workspace; @@ -106,7 +105,6 @@ pub use top_k::*; pub use top_p::*; pub use transformer::*; pub use update::*; -pub use user_hook_config::*; pub use user_hook_io::*; pub use validation::*; pub use workspace::*; diff --git a/crates/forge_domain/src/user_hook_config.rs b/crates/forge_domain/src/user_hook_config.rs deleted file mode 100644 index d97739e71e..0000000000 --- a/crates/forge_domain/src/user_hook_config.rs +++ /dev/null @@ -1,250 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; -use strum_macros::Display; - -/// Top-level user hook configuration. -/// -/// Maps hook event names to a list of matcher groups. This is deserialized -/// from the `"hooks"` key in `.forge/settings.json` or -/// `~/.forge/settings.json`. -/// -/// Example JSON: -/// ```json -/// { -/// "PreToolUse": [ -/// { "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo hi" }] } -/// ] -/// } -/// ``` -#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct UserHookConfig { - /// Map of event name -> list of matcher groups - #[serde(flatten)] - pub events: HashMap>, -} - -impl UserHookConfig { - /// Creates an empty user hook configuration. - pub fn new() -> Self { - Self { events: HashMap::new() } - } - - /// Returns the matcher groups for a given event name, or an empty slice if - /// none. - pub fn get_groups(&self, event: &UserHookEventName) -> &[UserHookMatcherGroup] { - self.events.get(event).map_or(&[], |v| v.as_slice()) - } - - /// Merges another config into this one, appending matcher groups for each - /// event. - pub fn merge(&mut self, other: UserHookConfig) { - for (event, groups) in other.events { - self.events.entry(event).or_default().extend(groups); - } - } - - /// Returns true if no hook events are configured. - pub fn is_empty(&self) -> bool { - self.events.is_empty() - } -} - -/// Supported hook event names that map to lifecycle points in the -/// orchestrator. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] -pub enum UserHookEventName { - /// Fired before a tool call executes. Can block execution. - PreToolUse, - /// Fired after a tool call succeeds. - PostToolUse, - /// Fired after a tool call fails. - PostToolUseFailure, - /// Fired when the agent finishes responding. Can block stop to continue. - Stop, - /// FIXME: Fired when a notification is sent; no lifecycle point fires this - /// event and no handler exists yet. - Notification, - /// Fired when a session starts or resumes. - SessionStart, - /// Fired when a session ends/terminates. - SessionEnd, - /// Fired when a user prompt is submitted. - UserPromptSubmit, - /// FIXME: Fired before context compaction; no lifecycle point fires this - /// event and no handler exists yet. - PreCompact, - /// FIXME: Fired after context compaction; no lifecycle point fires this - /// event and no handler exists yet. - PostCompact, - /// FIXME: Fired when a subagent starts; no lifecycle point fires this - SubagentStart, - /// FIXME: Fired when a subagent stops; no lifecycle point fires this - SubagentStop, - /// FIXME: no lifecycle point fires this - PermissionRequest, - /// FIXME: no lifecycle point fires this - Setup, -} - -/// A matcher group pairs an optional regex matcher with a list of hook -/// handlers. -/// -/// When a lifecycle event fires, only matcher groups whose `matcher` regex -/// matches the relevant event context (e.g., tool name) will have their hooks -/// executed. If `matcher` is `None` (or an empty string, which is normalized -/// to `None`), all hooks in this group fire unconditionally. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct UserHookMatcherGroup { - /// Optional regex pattern to match against (e.g., tool name for - /// PreToolUse/PostToolUse). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub matcher: Option, - - /// List of hook handlers to execute when this matcher matches. - #[serde(default)] - pub hooks: Vec, -} - -/// A single hook handler entry that defines what action to take. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct UserHookEntry { - /// The type of hook handler. - #[serde(rename = "type")] - pub hook_type: UserHookType, - - /// The shell command to execute (for `Command` type hooks). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub command: Option, - - /// Timeout in milliseconds for this hook. Defaults to 600000ms (10 - /// minutes). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout: Option, -} - -/// The type of hook handler to execute. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum UserHookType { - /// Executes a shell command, piping JSON to stdin and reading JSON from - /// stdout. - Command, -} - -/// Wrapper for the top-level settings JSON that contains the hooks key. -/// -/// Used for deserializing the entire settings file and extracting just the -/// `"hooks"` section. -#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct UserSettings { - /// User hook configuration. - #[serde(default)] - pub hooks: UserHookConfig, -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::*; - - #[test] - fn test_deserialize_empty_config() { - let json = r#"{}"#; - let actual: UserHookConfig = serde_json::from_str(json).unwrap(); - let expected = UserHookConfig::new(); - assert_eq!(actual, expected); - } - - #[test] - fn test_deserialize_pre_tool_use_hook() { - let json = include_str!("fixtures/hook_pre_tool_use.json"); - - let actual: UserHookConfig = serde_json::from_str(json).unwrap(); - let groups = actual.get_groups(&UserHookEventName::PreToolUse); - - assert_eq!(groups.len(), 1); - assert_eq!(groups[0].matcher, Some("Bash".to_string())); - assert_eq!(groups[0].hooks.len(), 1); - assert_eq!(groups[0].hooks[0].hook_type, UserHookType::Command); - assert_eq!( - groups[0].hooks[0].command, - Some("echo 'blocked'".to_string()) - ); - } - - #[test] - fn test_deserialize_multiple_events() { - let json = include_str!("fixtures/hook_multiple_events.json"); - - let actual: UserHookConfig = serde_json::from_str(json).unwrap(); - - assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 1); - assert_eq!(actual.get_groups(&UserHookEventName::PostToolUse).len(), 1); - assert_eq!(actual.get_groups(&UserHookEventName::Stop).len(), 1); - assert!( - actual - .get_groups(&UserHookEventName::SessionStart) - .is_empty() - ); - } - - #[test] - fn test_deserialize_hook_with_timeout() { - let json = include_str!("fixtures/hook_with_timeout.json"); - - let actual: UserHookConfig = serde_json::from_str(json).unwrap(); - let groups = actual.get_groups(&UserHookEventName::PreToolUse); - - assert_eq!(groups[0].hooks[0].timeout, Some(30000)); - } - - #[test] - fn test_merge_configs() { - let json1 = include_str!("fixtures/hook_merge_config_1.json"); - let json2 = include_str!("fixtures/hook_merge_config_2.json"); - - let mut actual: UserHookConfig = serde_json::from_str(json1).unwrap(); - let config2: UserHookConfig = serde_json::from_str(json2).unwrap(); - actual.merge(config2); - - assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 2); - assert_eq!(actual.get_groups(&UserHookEventName::Stop).len(), 1); - } - - #[test] - fn test_deserialize_settings_with_hooks() { - let json = include_str!("fixtures/hook_settings_with_hooks.json"); - - let actual: UserSettings = serde_json::from_str(json).unwrap(); - - assert!(!actual.hooks.is_empty()); - assert_eq!( - actual - .hooks - .get_groups(&UserHookEventName::PreToolUse) - .len(), - 1 - ); - } - - #[test] - fn test_deserialize_settings_without_hooks() { - let json = r#"{}"#; - let actual: UserSettings = serde_json::from_str(json).unwrap(); - - assert!(actual.hooks.is_empty()); - } - - #[test] - fn test_no_matcher_group_fires_unconditionally() { - let json = include_str!("fixtures/hook_no_matcher.json"); - - let actual: UserHookConfig = serde_json::from_str(json).unwrap(); - let groups = actual.get_groups(&UserHookEventName::PostToolUse); - - assert_eq!(groups.len(), 1); - assert_eq!(groups[0].matcher, None); - } -} diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index d349a3a94b..d28fd96702 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -1,16 +1,13 @@ -use std::path::Path; use std::sync::Arc; -use forge_app::{EnvironmentInfra, FileInfoInfra, FileReaderInfra}; -use forge_domain::{UserHookConfig, UserSettings}; +use forge_app::EnvironmentInfra; +use forge_config::UserHookConfig; -/// Loads and merges user hook configurations from the three settings file -/// locations using infrastructure abstractions. +/// Loads user hook configuration from `.forge.toml` via the config pipeline. /// -/// Resolution order (all merged, not overridden): -/// 1. `~/.forge/settings.json` (user-level, applies to all projects) -/// 2. `.forge/settings.json` (project-level, committable) -/// 3. `.forge/settings.local.json` (project-level, gitignored) +/// Hook configuration is read from the `[hooks]` section of the user's +/// `.forge.toml` file, automatically merged with defaults by the +/// `ConfigReader` layered config system. pub struct ForgeUserHookConfigService(Arc); impl ForgeUserHookConfigService { @@ -20,77 +17,12 @@ impl ForgeUserHookConfigService { } } -impl ForgeUserHookConfigService { - /// Loads a single settings file and extracts hook configuration. - /// - /// Returns `Ok(None)` if the file does not exist or cannot be read. - /// Returns `Err` if the file exists but fails to deserialize, including the - /// file path in the error message. - async fn load_file(&self, path: &Path) -> anyhow::Result> { - if !self.0.exists(path).await? { - return Ok(None); - } - let contents = self - .0 - .read_utf8(path) - .await - .map_err(|e| anyhow::anyhow!("Failed to read '{}': {}", path.display(), e))?; - - match serde_json::from_str::(&contents) { - Ok(settings) => { - if settings.hooks.is_empty() { - Ok(None) - } else { - Ok(Some(settings.hooks)) - } - } - Err(e) => Err(anyhow::anyhow!( - "Failed to deserialize '{}': {}", - path.display(), - e - )), - } - } -} - #[async_trait::async_trait] -impl forge_app::UserHookConfigService +impl> forge_app::UserHookConfigService for ForgeUserHookConfigService { async fn get_user_hook_config(&self) -> anyhow::Result { - let env = self.0.get_environment(); - - // Collect all candidate paths in resolution order - let mut paths: Vec = Vec::new(); - if let Some(home) = &env.home { - paths.push(home.join("forge").join("settings.json")); - } - paths.push(env.cwd.join(".forge").join("settings.json")); - paths.push(env.cwd.join(".forge").join("settings.local.json")); - - // Load every file, keeping the (path, result) pairs - let results = - futures::future::join_all(paths.iter().map(|path| self.load_file(path))).await; - - // Collect the error message from every file that failed - let errors: Vec = results - .iter() - .filter_map(|r| r.as_ref().err().map(|e| e.to_string())) - .collect(); - - if !errors.is_empty() { - return Err(anyhow::anyhow!("{}", errors.join("\n\n"))); - } - - // Merge every successfully loaded config - let mut config = UserHookConfig::new(); - for result in results { - if let Ok(Some(file_config)) = result { - config.merge(file_config); - } - } - - Ok(config) + Ok(self.0.get_config()?.hooks.unwrap_or_default()) } } @@ -100,235 +32,49 @@ mod tests { use fake::Fake; use forge_app::UserHookConfigService; + use forge_config::UserHookEventName; use pretty_assertions::assert_eq; use super::*; #[tokio::test] - async fn test_load_file_valid_settings() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - std::fs::write( - &settings_path, - r#"{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } - ] - } - }"#, - ) - .unwrap(); + async fn test_get_user_hook_config_returns_hooks_from_config() { + let hook_json = r#"{ + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "check.sh" }] } + ] + }"#; + let hooks: forge_config::UserHookConfig = serde_json::from_str(hook_json).unwrap(); + let config = forge_config::ForgeConfig { hooks: Some(hooks), ..Default::default() }; + let service = fixture(config); - let service = fixture(None, PathBuf::from("/nonexistent")); + let actual = service.get_user_hook_config().await.unwrap(); - let actual = service.load_file(&settings_path).await.unwrap(); - assert!(actual.is_some()); - let config = actual.unwrap(); + assert!(!actual.is_empty()); assert_eq!( - config - .get_groups(&forge_domain::UserHookEventName::PreToolUse) - .len(), + actual.get_groups(&UserHookEventName::PreToolUse).len(), 1 ); } #[tokio::test] - async fn test_load_file_settings_without_hooks() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - std::fs::write(&settings_path, r#"{"other_key": "value"}"#).unwrap(); - - let service = fixture(None, PathBuf::from("/nonexistent")); - - let actual = service.load_file(&settings_path).await.unwrap(); - assert!(actual.is_none()); - } - - #[tokio::test] - async fn test_load_file_invalid_json_returns_error_with_path() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - std::fs::write(&settings_path, r#"{ invalid json }"#).unwrap(); - - let service = fixture(None, PathBuf::from("/nonexistent")); - - let actual = service.load_file(&settings_path).await; - assert!(actual.is_err()); - let err = actual.unwrap_err().to_string(); - assert!( - err.contains(&settings_path.display().to_string()), - "Error message should contain the file path, got: {err}" - ); - } - - #[tokio::test] - async fn test_get_user_hook_config_reports_all_invalid_files() { - let project_dir = tempfile::tempdir().unwrap(); - let project_forge_dir = project_dir.path().join(".forge"); - std::fs::create_dir_all(&project_forge_dir).unwrap(); - - // Both project files have invalid JSON - std::fs::write(project_forge_dir.join("settings.json"), r#"{ bad }"#).unwrap(); - std::fs::write( - project_forge_dir.join("settings.local.json"), - r#"{ also bad }"#, - ) - .unwrap(); - - let service = fixture(None, project_dir.path().to_path_buf()); - - let actual = service.get_user_hook_config().await; - assert!(actual.is_err()); - let err = actual.unwrap_err().to_string(); - assert!( - err.contains("settings.json"), - "Error should mention settings.json, got: {err}" - ); - assert!( - err.contains("settings.local.json"), - "Error should mention settings.local.json, got: {err}" - ); - } - - #[tokio::test] - async fn test_get_user_hook_config_nonexistent_paths() { - let service = fixture( - Some(PathBuf::from("/nonexistent/home")), - PathBuf::from("/nonexistent/project"), - ); + async fn test_get_user_hook_config_returns_empty_when_no_hooks() { + let config = forge_config::ForgeConfig::default(); + let service = fixture(config); let actual = service.get_user_hook_config().await.unwrap(); - assert!(actual.is_empty()); - } - - #[tokio::test] - async fn test_get_user_hook_config_merges_all_sources() { - // Set up a fake home directory - let home_dir = tempfile::tempdir().unwrap(); - let forge_dir = home_dir.path().join("forge"); - std::fs::create_dir_all(&forge_dir).unwrap(); - std::fs::write( - forge_dir.join("settings.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [{ "type": "command", "command": "global.sh" }] } - ] - } - }"#, - ) - .unwrap(); - // Set up a project directory - let project_dir = tempfile::tempdir().unwrap(); - let project_forge_dir = project_dir.path().join(".forge"); - std::fs::create_dir_all(&project_forge_dir).unwrap(); - std::fs::write( - project_forge_dir.join("settings.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { "matcher": "Write", "hooks": [{ "type": "command", "command": "project.sh" }] } - ] - } - }"#, - ) - .unwrap(); - std::fs::write( - project_forge_dir.join("settings.local.json"), - r#"{ - "hooks": { - "Stop": [ - { "hooks": [{ "type": "command", "command": "local-stop.sh" }] } - ] - } - }"#, - ) - .unwrap(); - - let service = fixture( - Some(home_dir.path().to_path_buf()), - project_dir.path().to_path_buf(), - ); - - let actual = service.get_user_hook_config().await.unwrap(); - - // PreToolUse should have 2 groups (global + project) - assert_eq!( - actual - .get_groups(&forge_domain::UserHookEventName::PreToolUse) - .len(), - 2 - ); - // Stop should have 1 group (local) - assert_eq!( - actual - .get_groups(&forge_domain::UserHookEventName::Stop) - .len(), - 1 - ); + assert!(actual.is_empty()); } // --- Test helpers --- - fn fixture(home: Option, cwd: PathBuf) -> ForgeUserHookConfigService { - ForgeUserHookConfigService::new(Arc::new(TestInfra { home, cwd })) + fn fixture(config: forge_config::ForgeConfig) -> ForgeUserHookConfigService { + ForgeUserHookConfigService::new(Arc::new(TestInfra { config })) } struct TestInfra { - home: Option, - cwd: PathBuf, - } - - #[async_trait::async_trait] - impl FileInfoInfra for TestInfra { - async fn is_binary(&self, _path: &Path) -> anyhow::Result { - Ok(false) - } - - async fn is_file(&self, path: &Path) -> anyhow::Result { - Ok(tokio::fs::metadata(path) - .await - .map(|m| m.is_file()) - .unwrap_or(false)) - } - - async fn exists(&self, path: &Path) -> anyhow::Result { - Ok(tokio::fs::try_exists(path).await.unwrap_or(false)) - } - - async fn file_size(&self, path: &Path) -> anyhow::Result { - Ok(tokio::fs::metadata(path).await?.len()) - } - } - - #[async_trait::async_trait] - impl FileReaderInfra for TestInfra { - async fn read_utf8(&self, path: &Path) -> anyhow::Result { - Ok(tokio::fs::read_to_string(path).await?) - } - - fn read_batch_utf8( - &self, - _batch_size: usize, - _paths: Vec, - ) -> impl futures::Stream)> + Send { - futures::stream::empty() - } - - async fn read(&self, path: &Path) -> anyhow::Result> { - Ok(tokio::fs::read(path).await?) - } - - async fn range_read_utf8( - &self, - _path: &Path, - _start_line: u64, - _end_line: u64, - ) -> anyhow::Result<(String, forge_domain::FileInfo)> { - unimplemented!("not needed for tests") - } + config: forge_config::ForgeConfig, } impl EnvironmentInfra for TestInfra { @@ -344,13 +90,13 @@ mod tests { fn get_environment(&self) -> forge_domain::Environment { let mut env: forge_domain::Environment = fake::Faker.fake(); - env.home = self.home.clone(); - env.cwd = self.cwd.clone(); + env.home = Some(PathBuf::from("/nonexistent/home")); + env.cwd = PathBuf::from("/nonexistent/project"); env } fn get_config(&self) -> anyhow::Result { - unimplemented!("not needed for tests") + Ok(self.config.clone()) } async fn update_environment( diff --git a/forge.schema.json b/forge.schema.json index f925ff6a5f..8cbb1c4ea9 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -56,6 +56,17 @@ "null" ] }, + "hooks": { + "description": "User hook configuration loaded from the `[hooks]` section.\n\nMaps lifecycle event names (e.g. `PreToolUse`, `Stop`) to lists of\nmatcher groups that execute shell commands at each event point.", + "anyOf": [ + { + "$ref": "#/$defs/UserHookConfig" + }, + { + "type": "null" + } + ] + }, "http": { "description": "HTTP client settings including proxy, TLS, and timeout configuration.", "anyOf": [ @@ -874,6 +885,76 @@ "always" ] }, + "UserHookConfig": { + "description": "Top-level user hook configuration.\n\nMaps hook event names to a list of matcher groups. This is deserialized\nfrom the `hooks` section in `.forge.toml`.\n\nExample TOML:\n```toml\n[[hooks.PreToolUse]]\nmatcher = \"Bash\"\n\n [[hooks.PreToolUse.hooks]]\n type = \"command\"\n command = \"echo hi\"\n```", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/UserHookMatcherGroup" + } + } + }, + "UserHookEntry": { + "description": "A single hook handler entry that defines what action to take.", + "type": "object", + "properties": { + "command": { + "description": "The shell command to execute (for `Command` type hooks).", + "type": [ + "string", + "null" + ] + }, + "timeout": { + "description": "Timeout in milliseconds for this hook. Defaults to 600000ms (10\nminutes).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "type": { + "description": "The type of hook handler.", + "$ref": "#/$defs/UserHookType" + } + }, + "required": [ + "type" + ] + }, + "UserHookMatcherGroup": { + "description": "A matcher group pairs an optional regex matcher with a list of hook\nhandlers.\n\nWhen a lifecycle event fires, only matcher groups whose `matcher` regex\nmatches the relevant event context (e.g., tool name) will have their hooks\nexecuted. If `matcher` is `None` (or an empty string, which is normalized\nto `None`), all hooks in this group fire unconditionally.", + "type": "object", + "properties": { + "hooks": { + "description": "List of hook handlers to execute when this matcher matches.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/UserHookEntry" + } + }, + "matcher": { + "description": "Optional regex pattern to match against (e.g., tool name for\nPreToolUse/PostToolUse).", + "type": [ + "string", + "null" + ] + } + } + }, + "UserHookType": { + "description": "The type of hook handler to execute.", + "oneOf": [ + { + "description": "Executes a shell command, piping JSON to stdin and reading JSON from\nstdout.", + "type": "string", + "const": "command" + } + ] + }, "double": { "type": "number", "format": "double" From 1d26f4e5ca2db9b11d10ebe68846e0f3d88d6b40 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:39:18 -0400 Subject: [PATCH 43/64] feat(config): remove hook_timeout_ms from default .forge.toml --- crates/forge_config/.forge.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index b1b8909189..f807226064 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -24,7 +24,6 @@ sem_search_top_k = 10 services_url = "https://api.forgecode.dev/" tool_supported = true tool_timeout_secs = 300 -hook_timeout_ms = 600000 top_k = 30 top_p = 0.8 From e0bc85eaffbca241a6b280c9b6f05473bd0ac124 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:41:19 +0000 Subject: [PATCH 44/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/user_hook_handler.rs | 13 ++++--------- crates/forge_config/src/config.rs | 13 ++++++------- crates/forge_config/src/hooks.rs | 4 +--- crates/forge_services/src/user_hook_config.rs | 5 +---- 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 8a2612ebac..868479e7f9 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -4,14 +4,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use async_trait::async_trait; -use forge_config::{ - UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, -}; +use forge_config::{UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup}; use forge_domain::{ ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, - HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, - ResponsePayload, Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, - ToolcallStartPayload, + HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, ResponsePayload, + Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, }; use forge_template::Element; use regex::Regex; @@ -664,9 +661,7 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; - use forge_config::{ - UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType, - }; + use forge_config::{UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType}; use forge_domain::{CommandOutput, HookExecutionResult}; use pretty_assertions::assert_eq; diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 5fd12060a9..0c18a8f3f9 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use crate::reader::ConfigReader; use crate::writer::ConfigWriter; use crate::{ - AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, Update, - UserHookConfig, + AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, + Update, UserHookConfig, }; /// Wire protocol a provider uses for chat completions. @@ -348,7 +348,9 @@ mod tests { #[test] fn test_hooks_toml_round_trip() { - use crate::{UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType}; + use crate::{ + UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup, UserHookType, + }; let mut events = std::collections::HashMap::new(); events.insert( @@ -362,10 +364,7 @@ mod tests { }], }], ); - let fixture = ForgeConfig { - hooks: Some(UserHookConfig { events }), - ..Default::default() - }; + let fixture = ForgeConfig { hooks: Some(UserHookConfig { events }), ..Default::default() }; let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); let actual: ForgeConfig = toml_edit::de::from_str(&toml).unwrap(); diff --git a/crates/forge_config/src/hooks.rs b/crates/forge_config/src/hooks.rs index fde6adcc84..d1d7a4d4e2 100644 --- a/crates/forge_config/src/hooks.rs +++ b/crates/forge_config/src/hooks.rs @@ -46,9 +46,7 @@ impl UserHookConfig { /// Supported hook event names that map to lifecycle points in the /// orchestrator. -#[derive( - Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, JsonSchema, Dummy, -)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, JsonSchema, Dummy)] pub enum UserHookEventName { /// Fired before a tool call executes. Can block execution. PreToolUse, diff --git a/crates/forge_services/src/user_hook_config.rs b/crates/forge_services/src/user_hook_config.rs index d28fd96702..8834063bf7 100644 --- a/crates/forge_services/src/user_hook_config.rs +++ b/crates/forge_services/src/user_hook_config.rs @@ -51,10 +51,7 @@ mod tests { let actual = service.get_user_hook_config().await.unwrap(); assert!(!actual.is_empty()); - assert_eq!( - actual.get_groups(&UserHookEventName::PreToolUse).len(), - 1 - ); + assert_eq!(actual.get_groups(&UserHookEventName::PreToolUse).len(), 1); } #[tokio::test] From 847650e08a1c95d32491711971c379794b3a03a9 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:49:55 -0400 Subject: [PATCH 45/64] fix(hooks): escape newline in stop hook continue message --- crates/forge_app/src/hooks/user_hook_handler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 868479e7f9..0b15b5e847 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -628,7 +628,7 @@ impl EventHandle> for UserHookHandl if let Some(context) = conversation.context.as_mut() { let continue_msg = Element::new("important") .text( - "A Stop hook has requested the conversation to continue. + "A Stop hook has requested the conversation to continue. \ You MUST acknowledge this and continue working on the task.", ) .append( From 824b2d615710963415f97cba8ed4405fd2566128 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:50:34 -0400 Subject: [PATCH 46/64] refactor(hooks): simplify lifecycle hook handling in orchestrator --- crates/forge_app/src/orch.rs | 150 ++++++++--------------------------- 1 file changed, 35 insertions(+), 115 deletions(-) diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index 6de402ae13..b5435c4afb 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -4,7 +4,7 @@ use std::time::Duration; use async_recursion::async_recursion; use derive_setters::Setters; -use forge_domain::{Agent, PromptSuppressed, StopBlocked, *}; +use forge_domain::{Agent, *}; use forge_template::Element; use futures::future::join_all; use tokio::sync::Notify; @@ -114,53 +114,30 @@ impl> Orc notifier.notified().await; } - // Fire the ToolcallStart lifecycle event. - // If a hook returns an error (e.g., PreToolUse hook blocked the - // call), skip execution and record an error result instead. - // A PreToolUse hook may also modify the tool call arguments in-flight - // via the AllowWithUpdate path. - let mut toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( + // Fire the ToolcallStart lifecycle event + let toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( self.agent.clone(), self.agent.model.clone(), ToolcallStartPayload::new((*tool_call).clone()), )); - let hook_result = self - .hook - .handle(&mut toolcall_start_event, &mut self.conversation) - .await; - - let (effective_tool_call, tool_result) = if let Err(hook_err) = hook_result { - // Hook blocked this tool call — notify the UI and produce an - // error ToolResult so the model sees feedback without aborting. - self.send(ChatResponse::HookError { - tool_name: tool_call.name.clone(), - reason: hook_err.to_string(), - }) + self.hook + .handle(&toolcall_start_event, &mut self.conversation) .await?; - let result = ToolResult::from((*tool_call).clone()).failure(hook_err); - ((*tool_call).clone(), result) - } else { - // Extract the (possibly modified) tool call from the event. - // A PreToolUse hook may have updated the tool call arguments. - let effective = match toolcall_start_event { - LifecycleEvent::ToolcallStart(data) => data.payload.tool_call, - _ => unreachable!("ToolcallStart event cannot change variant"), - }; - let result = self - .services - .call(&self.agent, tool_context, effective.clone()) - .await; - (effective, result) - }; + + // Execute the tool + let tool_result = self + .services + .call(&self.agent, tool_context, (*tool_call).clone()) + .await; // Fire the ToolcallEnd lifecycle event (fires on both success and failure) - let mut toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( + let toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( self.agent.clone(), self.agent.model.clone(), - ToolcallEndPayload::new(effective_tool_call.clone(), tool_result.clone()), + ToolcallEndPayload::new((*tool_call).clone(), tool_result.clone()), )); self.hook - .handle(&mut toolcall_end_event, &mut self.conversation) + .handle(&toolcall_end_event, &mut self.conversation) .await?; // Send the end notification for system tools and not agent as a tool @@ -168,7 +145,7 @@ impl> Orc self.send(ChatResponse::ToolCallEnd(tool_result.clone())) .await?; } - other_results.push((effective_tool_call, tool_result)); + other_results.push(((*tool_call).clone(), tool_result)); } // Reconstruct results in the original order of tool_calls. @@ -254,13 +231,13 @@ impl> Orc let mut context = self.conversation.context.clone().unwrap_or_default(); // Fire the Start lifecycle event - let mut start_event = LifecycleEvent::Start(EventData::new( + let start_event = LifecycleEvent::Start(EventData::new( self.agent.clone(), model_id.clone(), StartPayload, )); self.hook - .handle(&mut start_event, &mut self.conversation) + .handle(&start_event, &mut self.conversation) .await?; // Signals that the loop should suspend (task may or may not be completed) @@ -271,11 +248,6 @@ impl> Orc let mut request_count = 0; - // Tracks whether the End lifecycle event has already been fired - // inside the loop (e.g. to give the Stop hook a chance to force - // continuation). When true, the post-loop End event is skipped. - let mut end_event_fired = false; - // Retrieve the number of requests allowed per tick. let max_requests_per_turn = self.agent.max_requests_per_turn; let tool_context = @@ -286,29 +258,15 @@ impl> Orc self.conversation.context = Some(context.clone()); self.services.update(self.conversation.clone()).await?; - // Fire the Request lifecycle event. - // A UserPromptSubmit hook may suppress the prompt by returning - // PromptSuppressed. In that case, we exit the loop cleanly - // without making the LLM call. - let mut request_event = LifecycleEvent::Request(EventData::new( + // Fire the Request lifecycle event + let request_event = LifecycleEvent::Request(EventData::new( self.agent.clone(), model_id.clone(), RequestPayload::new(request_count), )); - if let Err(e) = self - .hook - .handle(&mut request_event, &mut self.conversation) - .await - { - if e.downcast_ref::().is_some() { - // Prompt was blocked by a UserPromptSubmit hook. - // Persist the conversation (which now contains the feedback - // message) and exit cleanly. - self.services.update(self.conversation.clone()).await?; - break; - } - return Err(e); - } + self.hook + .handle(&request_event, &mut self.conversation) + .await?; let message = crate::retry::retry_with_config( &self.config.clone().retry.unwrap_or_default(), @@ -341,13 +299,13 @@ impl> Orc .await?; // Fire the Response lifecycle event - let mut response_event = LifecycleEvent::Response(EventData::new( + let response_event = LifecycleEvent::Response(EventData::new( self.agent.clone(), model_id.clone(), ResponsePayload::new(message.clone()), )); self.hook - .handle(&mut response_event, &mut self.conversation) + .handle(&response_event, &mut self.conversation) .await?; // Turn is completed, if finish_reason is 'stop'. Gemini models return stop as @@ -445,57 +403,19 @@ impl> Orc tool_context.with_metrics(|metrics| { self.conversation.metrics = metrics.clone(); })?; - - // If the agent is about to stop (task complete), fire the End - // event inside the loop so a Stop hook can force continuation. - if should_yield && is_complete { - let end_result = self - .hook - .handle( - &mut LifecycleEvent::End(EventData::new( - self.agent.clone(), - model_id.clone(), - EndPayload, - )), - &mut self.conversation, - ) - .await; - match end_result { - Err(e) if e.downcast_ref::().is_some() => { - // Stop hook wants to continue — re-enter the loop. - // Update context from conversation (handler may have - // injected a continue message). - if let Some(updated_context) = &self.conversation.context { - context = updated_context.clone(); - } - should_yield = false; - is_complete = false; - end_event_fired = true; - continue; - } - Err(e) => return Err(e), - Ok(()) => { - end_event_fired = true; - } - } - } } - // Fire the End lifecycle event if it wasn't already fired inside the - // loop (e.g. when yielding due to a tool requesting follow-up, or - // when the Stop hook did not block). - if !end_event_fired { - self.hook - .handle( - &mut LifecycleEvent::End(EventData::new( - self.agent.clone(), - model_id.clone(), - EndPayload, - )), - &mut self.conversation, - ) - .await?; - } + // Fire the End lifecycle event (title will be set here by the hook) + self.hook + .handle( + &LifecycleEvent::End(EventData::new( + self.agent.clone(), + model_id.clone(), + EndPayload, + )), + &mut self.conversation, + ) + .await?; self.services.update(self.conversation.clone()).await?; From f7e17e81a579d4d804c8113dbfbe2518ae0b25f1 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:50:37 -0400 Subject: [PATCH 47/64] feat(hooks): emit user hook warnings via lifecycle events --- crates/forge_app/src/agent_executor.rs | 1 + .../forge_app/src/hooks/user_hook_handler.rs | 301 +++++------------- crates/forge_app/src/orch.rs | 115 +++++-- crates/forge_domain/src/chat_response.rs | 7 + crates/forge_domain/src/hook.rs | 35 +- crates/forge_domain/src/user_hook_io.rs | 9 +- crates/forge_main/src/ui.rs | 6 + 7 files changed, 202 insertions(+), 272 deletions(-) diff --git a/crates/forge_app/src/agent_executor.rs b/crates/forge_app/src/agent_executor.rs index 6d4f4e543c..a2f990c299 100644 --- a/crates/forge_app/src/agent_executor.rs +++ b/crates/forge_app/src/agent_executor.rs @@ -112,6 +112,7 @@ impl> AgentEx ChatResponse::ToolCallEnd(_) => ctx.send(message).await?, ChatResponse::RetryAttempt { .. } => ctx.send(message).await?, ChatResponse::HookError { .. } => ctx.send(message).await?, + ChatResponse::HookWarning { .. } => ctx.send(message).await?, ChatResponse::Interrupt { reason } => { return Err(Error::AgentToolInterrupted(reason)) .context(format!( diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 0b15b5e847..cb8b1e4c65 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -1,16 +1,14 @@ use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use async_trait::async_trait; use forge_config::{UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup}; use forge_domain::{ - ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, - HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, ResponsePayload, - Role, StartPayload, StopBlocked, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, + Conversation, EndPayload, EventData, EventHandle, HookEventInput, HookExecutionResult, + HookInput, HookOutput, PromptSuppressed, RequestPayload, ResponsePayload, Role, StartPayload, + ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, }; -use forge_template::Element; use regex::Regex; use serde_json::Value; use tracing::{debug, warn}; @@ -33,8 +31,6 @@ pub struct UserHookHandler { config: UserHookConfig, cwd: PathBuf, env_vars: HashMap, - /// Tracks whether a Stop hook has already fired to prevent infinite loops. - stop_hook_active: std::sync::Arc, } impl UserHookHandler { @@ -68,7 +64,6 @@ impl UserHookHandler { config, cwd, env_vars: env_vars.into_iter().collect(), - stop_hook_active: std::sync::Arc::new(AtomicBool::new(false)), } } @@ -120,12 +115,13 @@ impl UserHookHandler { matching } - /// Executes a list of hook entries and returns their results. + /// Executes a list of hook entries and returns their results along with + /// any warnings for commands that failed to execute. async fn execute_hooks( &self, hooks: &[&UserHookEntry], input: &HookInput, - ) -> Vec + ) -> (Vec, Vec) where I: HookCommandService, { @@ -133,11 +129,12 @@ impl UserHookHandler { Ok(json) => json, Err(e) => { warn!(error = %e, "Failed to serialize hook input"); - return Vec::new(); + return (Vec::new(), vec![format!("Hook input serialization failed: {e}")]); } }; let mut results = Vec::new(); + let mut warnings = Vec::new(); for hook in hooks { if let Some(command) = &hook.command { match self @@ -153,19 +150,36 @@ impl UserHookHandler { ) .await { - Ok(result) => results.push(result), + Ok(result) => { + // Non-blocking errors (exit code 1, etc.) are warned + if result.is_non_blocking_error() { + let stderr = result.stderr.trim(); + let detail = if stderr.is_empty() { + format!("exit code {:?}", result.exit_code) + } else { + stderr.to_string() + }; + warnings.push(format!( + "Hook command '{command}' returned non-blocking error: {detail}" + )); + } + results.push(result); + } Err(e) => { warn!( command = command, error = %e, "Hook command failed to execute" ); + warnings.push(format!( + "Hook command '{command}' failed to execute: {e}" + )); } } } } - results + (results, warnings) } /// Processes hook results, returning a blocking reason if any hook blocked. @@ -188,14 +202,6 @@ impl UserHookHandler { return Some(reason); } - // Non-blocking errors (exit code 1, etc.) are logged but don't block - if result.is_non_blocking_error() { - warn!( - exit_code = ?result.exit_code, - stderr = result.stderr.as_str(), - "Hook command returned non-blocking error" - ); - } } None @@ -233,14 +239,6 @@ impl UserHookHandler { } } - // Non-blocking errors are logged but don't block - if result.is_non_blocking_error() { - warn!( - exit_code = ?result.exit_code, - stderr = result.stderr.as_str(), - "PreToolUse hook command returned non-blocking error" - ); - } } PreToolUseDecision::Allow @@ -263,7 +261,7 @@ enum PreToolUseDecision { impl EventHandle> for UserHookHandler { async fn handle( &self, - _event: &mut EventData, + event: &mut EventData, _conversation: &mut Conversation, ) -> anyhow::Result<()> { if !self.has_hooks(&UserHookEventName::SessionStart) { @@ -284,7 +282,8 @@ impl EventHandle> for UserHookHan event_data: HookEventInput::SessionStart { source: "startup".to_string() }, }; - let results = self.execute_hooks(&hooks, &input).await; + let (results, warnings) = self.execute_hooks(&hooks, &input).await; + event.warnings.extend(warnings); // FIXME: SessionStart hooks can provide additional context but not block; // additional_context is detected here but never injected into the conversation. @@ -349,31 +348,17 @@ impl EventHandle> for UserHookH event_data: HookEventInput::UserPromptSubmit { prompt }, }; - let results = self.execute_hooks(&hooks, &input).await; + let (results, warnings) = self.execute_hooks(&hooks, &input).await; + event.warnings.extend(warnings); if let Some(reason) = Self::process_results(&results) { debug!( reason = reason.as_str(), "UserPromptSubmit hook blocked with feedback" ); - // Inject feedback so the model sees why the prompt was flagged. - if let Some(context) = conversation.context.as_mut() { - let feedback_msg = Element::new("important") - .text( - "A UserPromptSubmit hook has blocked this prompt. \ - You MUST acknowledge this in your next response.", - ) - .append( - Element::new("hook_feedback") - .append(Element::new("event").text("UserPromptSubmit")) - .append(Element::new("status").text("blocked")) - .append(Element::new("reason").text(&reason)), - ) - .render(); - context - .messages - .push(ContextMessage::user(feedback_msg, None).into()); - } + event.warnings.push(format!( + "UserPromptSubmit hook blocked: {reason}" + )); // Signal the orchestrator to suppress this prompt entirely. return Err(anyhow::Error::from(PromptSuppressed(reason))); } @@ -436,7 +421,8 @@ impl EventHandle> for Use }, }; - let results = self.execute_hooks(&hooks, &input).await; + let (results, warnings) = self.execute_hooks(&hooks, &input).await; + event.warnings.extend(warnings); let decision = Self::process_pre_tool_use_output(&results); match decision { @@ -476,7 +462,7 @@ impl EventHandle> for UserH async fn handle( &self, event: &mut EventData, - conversation: &mut Conversation, + _conversation: &mut Conversation, ) -> anyhow::Result<()> { let is_error = event.payload.result.is_error(); let event_name = if is_error { @@ -489,9 +475,9 @@ impl EventHandle> for UserH return Ok(()); } - let tool_name = event.payload.tool_call.name.as_str(); + let tool_name = event.payload.tool_call.name.as_str().to_string(); let groups = self.config.get_groups(&event_name); - let hooks = Self::find_matching_hooks(groups, Some(tool_name)); + let hooks = Self::find_matching_hooks(groups, Some(&tool_name)); if hooks.is_empty() { return Ok(()); @@ -519,35 +505,20 @@ impl EventHandle> for UserH }, }; - let results = self.execute_hooks(&hooks, &input).await; + let (results, warnings) = self.execute_hooks(&hooks, &input).await; + event.warnings.extend(warnings); // PostToolUse can provide feedback via blocking if let Some(reason) = Self::process_results(&results) { debug!( - tool_name = tool_name, + tool_name = tool_name.as_str(), event = %event_name, reason = reason.as_str(), "PostToolUse hook blocked with feedback" ); - // Inject feedback as a user message - if let Some(context) = conversation.context.as_mut() { - let feedback_msg = Element::new("important") - .text( - "A post-tool-use hook has flagged the following. \ - You MUST acknowledge this in your next response.", - ) - .append( - Element::new("hook_feedback") - .append(Element::new("event").text(event_name.to_string())) - .append(Element::new("tool").text(tool_name)) - .append(Element::new("status").text("blocked")) - .append(Element::new("reason").text(&reason)), - ) - .render(); - context - .messages - .push(forge_domain::ContextMessage::user(feedback_msg, None).into()); - } + event.warnings.push(format!( + "{event_name}:{tool_name} hook blocked: {reason}" + )); } Ok(()) @@ -558,7 +529,7 @@ impl EventHandle> for UserH impl EventHandle> for UserHookHandler { async fn handle( &self, - _event: &mut EventData, + event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { // Fire SessionEnd hooks @@ -573,7 +544,8 @@ impl EventHandle> for UserHookHandl session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), event_data: HookEventInput::Empty {}, }; - self.execute_hooks(&hooks, &input).await; + let (_, session_warnings) = self.execute_hooks(&hooks, &input).await; + event.warnings.extend(session_warnings); } } @@ -582,18 +554,10 @@ impl EventHandle> for UserHookHandl return Ok(()); } - // Prevent infinite loops - let was_active = self.stop_hook_active.swap(true, Ordering::SeqCst); - if was_active { - debug!("Stop hook already active, skipping to prevent infinite loop"); - return Ok(()); - } - let groups = self.config.get_groups(&UserHookEventName::Stop); let hooks = Self::find_matching_hooks(groups, None); if hooks.is_empty() { - self.stop_hook_active.store(false, Ordering::SeqCst); return Ok(()); } @@ -612,46 +576,23 @@ impl EventHandle> for UserHookHandl cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), event_data: HookEventInput::Stop { - stop_hook_active: was_active, last_assistant_message, }, }; - let results = self.execute_hooks(&hooks, &input).await; + let (results, stop_warnings) = self.execute_hooks(&hooks, &input).await; + event.warnings.extend(stop_warnings); if let Some(reason) = Self::process_results(&results) { debug!( reason = reason.as_str(), - "Stop hook wants to continue conversation" + "Stop hook blocked (warning only, no continuation)" ); - // Inject a message to continue the conversation - if let Some(context) = conversation.context.as_mut() { - let continue_msg = Element::new("important") - .text( - "A Stop hook has requested the conversation to continue. \ - You MUST acknowledge this and continue working on the task.", - ) - .append( - Element::new("hook_feedback") - .append(Element::new("event").text("Stop")) - .append(Element::new("status").text("continue")) - .append(Element::new("reason").text(&reason)), - ) - .render(); - context - .messages - .push(forge_domain::ContextMessage::user(continue_msg, None).into()); - } - // Keep stop_hook_active as true so the next Stop invocation - // sends stop_hook_active: true to the hook script, allowing it - // to detect re-entry and avoid infinite loops. - // Signal the orchestrator to continue the conversation - return Err(anyhow::Error::from(StopBlocked(reason))); + event.warnings.push(format!( + "Stop hook blocked: {reason}" + )); } - // Non-blocking: reset the stop hook active flag - self.stop_hook_active.store(false, Ordering::SeqCst); - Ok(()) } } @@ -1441,12 +1382,9 @@ mod tests { ); assert!(err.to_string().contains("policy violation")); - // Feedback should have been injected into conversation - let ctx = conversation.context.as_ref().unwrap(); - let last_msg = ctx.messages.last().unwrap(); - let content = last_msg.content().unwrap(); - assert!(content.contains("")); - assert!(content.contains("policy violation")); + // Warning should have been pushed to event.warnings + assert_eq!(event.warnings.len(), 1); + assert!(event.warnings[0].contains("policy violation")); } #[tokio::test] @@ -1591,7 +1529,7 @@ mod tests { } // ========================================================================= - // BUG-2 Tests: Stop hook blocking must return Err(StopBlocked) + // Stop hook tests: Stop hooks fire, collect warnings, never block // ========================================================================= /// Helper: creates a UserHookHandler with Stop config and a given infra. @@ -1619,7 +1557,7 @@ mod tests { } #[tokio::test] - async fn test_stop_hook_block_returns_stop_blocked_error() { + async fn test_stop_hook_exit_code_2_produces_warning() { #[derive(Clone)] struct StopBlockInfra; @@ -1647,17 +1585,11 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.downcast_ref::().is_some()); - assert!(err.to_string().contains("keep working")); - - // Continue message should be injected - let ctx = conversation.context.as_ref().unwrap(); - let last_msg = ctx.messages.last().unwrap(); - let content = last_msg.content().unwrap(); - assert!(content.contains("")); - assert!(content.contains("continue")); + // Stop hooks no longer block -- result is Ok + assert!(result.is_ok()); + // Warning should have been pushed to event.warnings + assert!(!event.warnings.is_empty()); + assert!(event.warnings.iter().any(|w| w.contains("keep working"))); } #[tokio::test] @@ -1676,59 +1608,7 @@ mod tests { } #[tokio::test] - async fn test_stop_hook_active_guard_prevents_reentry() { - #[derive(Clone)] - struct StopBlockInfra2; - - #[async_trait::async_trait] - impl HookCommandService for StopBlockInfra2 { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(2), - stdout: String::new(), - stderr: "keep working".to_string(), - }) - } - } - - let handler = stop_handler(StopBlockInfra2); - - // Simulate stop_hook_active already being true (re-entrant) - handler - .stop_hook_active - .store(true, std::sync::atomic::Ordering::SeqCst); - - let mut event = end_event(); - let mut conversation = conversation_with_user_msg("hello"); - - // Second call should be a no-op (guard prevents re-entry) - let result = handler.handle(&mut event, &mut conversation).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_stop_hook_active_flag_reset_after_completion() { - let handler = stop_handler(NullInfra); - let mut event = end_event(); - let mut conversation = conversation_with_user_msg("hello"); - - // After a successful (non-blocking) call, flag should be reset - handler.handle(&mut event, &mut conversation).await.unwrap(); - let actual = handler - .stop_hook_active - .load(std::sync::atomic::Ordering::SeqCst); - assert!(!actual); - } - - #[tokio::test] - async fn test_stop_hook_block_json_continue_false() { + async fn test_stop_hook_json_continue_false_produces_warning() { #[derive(Clone)] struct StopJsonBlockInfra; @@ -1756,16 +1636,16 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.downcast_ref::().is_some()); + // Stop hooks no longer block -- result is Ok + assert!(result.is_ok()); + // Warning about blocking should be present + assert!(event.warnings.iter().any(|w| w.contains("Stop hook blocked"))); } #[tokio::test] - async fn test_session_end_hooks_still_fire_on_block() { - // When Stop blocks, SessionEnd hooks (fired before Stop) should still - // have executed. We verify by configuring both SessionEnd and Stop hooks - // and checking that the handler processes both. + async fn test_session_end_and_stop_hooks_both_fire() { + // Both SessionEnd and Stop hooks should execute. Stop hooks produce + // warnings but never block. use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; @@ -1784,7 +1664,7 @@ mod tests { _: HashMap, ) -> anyhow::Result { self.call_count.fetch_add(1, AtomicOrdering::SeqCst); - // Return blocking for Stop hooks (exit 2) + // Return exit 2 (would have been "blocking" before) Ok(forge_domain::CommandOutput { command, exit_code: Some(2), @@ -1814,8 +1694,8 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; - // Stop hook blocks => StopBlocked error - assert!(result.is_err()); + // No longer blocks -- result is Ok + assert!(result.is_ok()); // Both SessionEnd AND Stop hooks should have been called (2 total) let actual = call_count.load(AtomicOrdering::SeqCst); assert_eq!(actual, 2); @@ -1896,12 +1776,9 @@ mod tests { // PostToolUse does NOT block execution — always Ok assert!(result.is_ok()); - // But feedback should be injected - let ctx = conversation.context.as_ref().unwrap(); - let last_msg = ctx.messages.last().unwrap(); - let content = last_msg.content().unwrap(); - assert!(content.contains("")); - assert!(content.contains("sensitive data detected")); + // But warning should be pushed to event.warnings + assert_eq!(event.warnings.len(), 1); + assert!(event.warnings[0].contains("sensitive data detected")); } #[tokio::test] @@ -1934,10 +1811,8 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; assert!(result.is_ok()); - let ctx = conversation.context.as_ref().unwrap(); - let last_msg = ctx.messages.last().unwrap(); - let content = last_msg.content().unwrap(); - assert!(content.contains("PII detected")); + assert_eq!(event.warnings.len(), 1); + assert!(event.warnings[0].contains("PII detected")); } #[tokio::test] @@ -2031,10 +1906,8 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; assert!(result.is_ok()); - let ctx = conversation.context.as_ref().unwrap(); - let last_msg = ctx.messages.last().unwrap(); - let content = last_msg.content().unwrap(); - assert!(content.contains("error flagged")); + assert_eq!(event.warnings.len(), 1); + assert!(event.warnings[0].contains("error flagged")); } #[tokio::test] @@ -2066,10 +1939,8 @@ mod tests { handler.handle(&mut event, &mut conversation).await.unwrap(); - let ctx = conversation.context.as_ref().unwrap(); - let last_msg = ctx.messages.last().unwrap(); - let content = last_msg.content().unwrap(); - // The feedback should reference the tool name - assert!(content.contains("shell")); + // The warning should reference the tool name + assert_eq!(event.warnings.len(), 1); + assert!(event.warnings[0].contains("shell")); } } diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index b5435c4afb..32b106c049 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -4,7 +4,7 @@ use std::time::Duration; use async_recursion::async_recursion; use derive_setters::Setters; -use forge_domain::{Agent, *}; +use forge_domain::{Agent, PromptSuppressed, *}; use forge_template::Element; use futures::future::join_all; use tokio::sync::Notify; @@ -114,38 +114,63 @@ impl> Orc notifier.notified().await; } - // Fire the ToolcallStart lifecycle event - let toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( + // Fire the ToolcallStart lifecycle event. + // If a hook returns an error (e.g., PreToolUse hook blocked the + // call), skip execution and record an error result instead. + // A PreToolUse hook may also modify the tool call arguments in-flight + // via the AllowWithUpdate path. + let mut toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( self.agent.clone(), self.agent.model.clone(), ToolcallStartPayload::new((*tool_call).clone()), )); - self.hook - .handle(&toolcall_start_event, &mut self.conversation) - .await?; - - // Execute the tool - let tool_result = self - .services - .call(&self.agent, tool_context, (*tool_call).clone()) + let hook_result = self + .hook + .handle(&mut toolcall_start_event, &mut self.conversation) .await; + self.drain_hook_warnings(&mut toolcall_start_event).await?; + + let (effective_tool_call, tool_result) = if let Err(hook_err) = hook_result { + // Hook blocked this tool call — notify the UI and produce an + // error ToolResult so the model sees feedback without aborting. + self.send(ChatResponse::HookError { + tool_name: tool_call.name.clone(), + reason: hook_err.to_string(), + }) + .await?; + let result = ToolResult::from((*tool_call).clone()).failure(hook_err); + ((*tool_call).clone(), result) + } else { + // Extract the (possibly modified) tool call from the event. + // A PreToolUse hook may have updated the tool call arguments. + let effective = match toolcall_start_event { + LifecycleEvent::ToolcallStart(data) => data.payload.tool_call, + _ => unreachable!("ToolcallStart event cannot change variant"), + }; + let result = self + .services + .call(&self.agent, tool_context, effective.clone()) + .await; + (effective, result) + }; // Fire the ToolcallEnd lifecycle event (fires on both success and failure) - let toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( + let mut toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( self.agent.clone(), self.agent.model.clone(), - ToolcallEndPayload::new((*tool_call).clone(), tool_result.clone()), + ToolcallEndPayload::new(effective_tool_call.clone(), tool_result.clone()), )); self.hook - .handle(&toolcall_end_event, &mut self.conversation) + .handle(&mut toolcall_end_event, &mut self.conversation) .await?; + self.drain_hook_warnings(&mut toolcall_end_event).await?; // Send the end notification for system tools and not agent as a tool if is_system_tool { self.send(ChatResponse::ToolCallEnd(tool_result.clone())) .await?; } - other_results.push(((*tool_call).clone(), tool_result)); + other_results.push((effective_tool_call, tool_result)); } // Reconstruct results in the original order of tool_calls. @@ -165,6 +190,16 @@ impl> Orc Ok(tool_call_records) } + /// Drains any hook warnings from a lifecycle event and emits them to the + /// UI as `ChatResponse::HookWarning` messages. + async fn drain_hook_warnings(&self, event: &mut LifecycleEvent) -> anyhow::Result<()> { + let warnings = event.drain_warnings(); + for message in warnings { + self.send(ChatResponse::HookWarning { message }).await?; + } + Ok(()) + } + async fn send(&self, message: ChatResponse) -> anyhow::Result<()> { if let Some(sender) = &self.sender { sender.send(Ok(message)).await? @@ -231,14 +266,15 @@ impl> Orc let mut context = self.conversation.context.clone().unwrap_or_default(); // Fire the Start lifecycle event - let start_event = LifecycleEvent::Start(EventData::new( + let mut start_event = LifecycleEvent::Start(EventData::new( self.agent.clone(), model_id.clone(), StartPayload, )); self.hook - .handle(&start_event, &mut self.conversation) + .handle(&mut start_event, &mut self.conversation) .await?; + self.drain_hook_warnings(&mut start_event).await?; // Signals that the loop should suspend (task may or may not be completed) let mut should_yield = false; @@ -258,15 +294,30 @@ impl> Orc self.conversation.context = Some(context.clone()); self.services.update(self.conversation.clone()).await?; - // Fire the Request lifecycle event - let request_event = LifecycleEvent::Request(EventData::new( + // Fire the Request lifecycle event. + // A UserPromptSubmit hook may suppress the prompt by returning + // PromptSuppressed. In that case, we exit the loop cleanly + // without making the LLM call. + let mut request_event = LifecycleEvent::Request(EventData::new( self.agent.clone(), model_id.clone(), RequestPayload::new(request_count), )); - self.hook - .handle(&request_event, &mut self.conversation) - .await?; + if let Err(e) = self + .hook + .handle(&mut request_event, &mut self.conversation) + .await + { + self.drain_hook_warnings(&mut request_event).await?; + if e.downcast_ref::().is_some() { + // Prompt was blocked by a UserPromptSubmit hook. + // Persist the conversation and exit cleanly. + self.services.update(self.conversation.clone()).await?; + break; + } + return Err(e); + } + self.drain_hook_warnings(&mut request_event).await?; let message = crate::retry::retry_with_config( &self.config.clone().retry.unwrap_or_default(), @@ -299,14 +350,15 @@ impl> Orc .await?; // Fire the Response lifecycle event - let response_event = LifecycleEvent::Response(EventData::new( + let mut response_event = LifecycleEvent::Response(EventData::new( self.agent.clone(), model_id.clone(), ResponsePayload::new(message.clone()), )); self.hook - .handle(&response_event, &mut self.conversation) + .handle(&mut response_event, &mut self.conversation) .await?; + self.drain_hook_warnings(&mut response_event).await?; // Turn is completed, if finish_reason is 'stop'. Gemini models return stop as // finish reason with tool calls. @@ -406,16 +458,15 @@ impl> Orc } // Fire the End lifecycle event (title will be set here by the hook) + let mut end_event = LifecycleEvent::End(EventData::new( + self.agent.clone(), + model_id.clone(), + EndPayload, + )); self.hook - .handle( - &LifecycleEvent::End(EventData::new( - self.agent.clone(), - model_id.clone(), - EndPayload, - )), - &mut self.conversation, - ) + .handle(&mut end_event, &mut self.conversation) .await?; + self.drain_hook_warnings(&mut end_event).await?; self.services.update(self.conversation.clone()).await?; diff --git a/crates/forge_domain/src/chat_response.rs b/crates/forge_domain/src/chat_response.rs index 3f5f5347ec..60e1db2e3d 100644 --- a/crates/forge_domain/src/chat_response.rs +++ b/crates/forge_domain/src/chat_response.rs @@ -73,6 +73,13 @@ pub enum ChatResponse { /// output). reason: String, }, + /// A user-configured hook encountered an error or produced a warning. + /// Displayed in the UI as a warning regardless of whether the hook + /// blocked execution or not. + HookWarning { + /// Human-readable warning message. + message: String, + }, RetryAttempt { cause: Cause, duration: Duration, diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index fb729695c7..53388652b7 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -16,12 +16,15 @@ pub struct EventData { pub model_id: ModelId, /// Event-specific payload data pub payload: P, + /// Transient warnings collected by hook handlers. The orchestrator + /// drains these after each hook invocation and emits them to the UI. + pub warnings: Vec, } impl EventData

{ /// Creates a new event with the given agent, model ID, and payload pub fn new(agent: Agent, model_id: ModelId, payload: P) -> Self { - Self { agent, model_id, payload } + Self { agent, model_id, payload, warnings: Vec::new() } } } @@ -117,6 +120,20 @@ pub enum LifecycleEvent { ToolcallEnd(EventData), } +impl LifecycleEvent { + /// Drains all warnings from the inner `EventData`, regardless of variant. + pub fn drain_warnings(&mut self) -> Vec { + match self { + LifecycleEvent::Start(data) => data.warnings.drain(..).collect(), + LifecycleEvent::End(data) => data.warnings.drain(..).collect(), + LifecycleEvent::Request(data) => data.warnings.drain(..).collect(), + LifecycleEvent::Response(data) => data.warnings.drain(..).collect(), + LifecycleEvent::ToolcallStart(data) => data.warnings.drain(..).collect(), + LifecycleEvent::ToolcallEnd(data) => data.warnings.drain(..).collect(), + } + } +} + /// Trait for handling lifecycle events /// /// Implementations of this trait can be used to react to different @@ -414,22 +431,6 @@ impl std::fmt::Display for PromptSuppressed { impl std::error::Error for PromptSuppressed {} -/// Error indicating a Stop hook blocked the agent from stopping. -/// -/// When a Stop hook exits with code 2 or returns a blocking JSON decision, -/// the handler returns this error to signal the orchestrator that the agent -/// should continue working instead of stopping. -#[derive(Debug)] -pub struct StopBlocked(pub String); - -impl std::fmt::Display for StopBlocked { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Stop blocked by hook: {}", self.0) - } -} - -impl std::error::Error for StopBlocked {} - #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index ff5fb428b4..8af7b0444e 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -58,8 +58,6 @@ pub enum HookEventInput { }, /// Input for Stop events. Stop { - /// Whether a Stop hook has already fired (prevents infinite loops). - stop_hook_active: bool, /// The last assistant message text before the stop event. #[serde(default, skip_serializing_if = "Option::is_none")] last_assistant_message: Option, @@ -262,7 +260,6 @@ mod tests { cwd: "/project".to_string(), session_id: None, event_data: HookEventInput::Stop { - stop_hook_active: false, last_assistant_message: None, }, }; @@ -270,7 +267,6 @@ mod tests { let actual = serde_json::to_value(&fixture).unwrap(); assert_eq!(actual["hook_event_name"], "Stop"); - assert_eq!(actual["stop_hook_active"], false); assert!(actual.get("last_assistant_message").is_none()); } @@ -281,14 +277,12 @@ mod tests { cwd: "/project".to_string(), session_id: Some("sess-456".to_string()), event_data: HookEventInput::Stop { - stop_hook_active: true, last_assistant_message: Some("Here is the result.".to_string()), }, }; let actual = serde_json::to_value(&fixture).unwrap(); - assert_eq!(actual["stop_hook_active"], true); assert_eq!(actual["last_assistant_message"], "Here is the result."); } @@ -307,9 +301,8 @@ mod tests { assert_eq!(actual["cwd"], "/project"); assert_eq!(actual["session_id"], "sess-abc"); assert_eq!(actual["prompt"], "fix the bug"); - // No tool_name, stop_hook_active, or other variant fields present + // No tool_name or other variant fields present assert!(actual["tool_name"].is_null()); - assert!(actual["stop_hook_active"].is_null()); } #[test] diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 56f48c5bd8..c894da3504 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3379,6 +3379,12 @@ impl A + Send + Sync> UI )))?; self.spinner.start(None)?; } + ChatResponse::HookWarning { message } => { + writer.finish()?; + self.spinner.stop(None)?; + self.writeln_title(TitleFormat::warning(message))?; + self.spinner.start(None)?; + } ChatResponse::Interrupt { reason } => { writer.finish()?; self.spinner.stop(None)?; From 0dca15c844611ba4de385170c0ee3c4e08990ed7 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:01:42 -0400 Subject: [PATCH 48/64] refactor(hooks): accept mutable end event in pending todos handler --- crates/forge_app/src/hooks/pending_todos.rs | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/forge_app/src/hooks/pending_todos.rs b/crates/forge_app/src/hooks/pending_todos.rs index bad2b44fa6..08537805b2 100644 --- a/crates/forge_app/src/hooks/pending_todos.rs +++ b/crates/forge_app/src/hooks/pending_todos.rs @@ -42,7 +42,7 @@ impl PendingTodosHandler { impl EventHandle> for PendingTodosHandler { async fn handle( &self, - _event: &EventData, + _event: &mut EventData, conversation: &mut Conversation, ) -> anyhow::Result<()> { let pending_todos = conversation.metrics.get_active_todos(); @@ -163,11 +163,11 @@ mod tests { #[tokio::test] async fn test_no_pending_todos_does_nothing() { let handler = PendingTodosHandler::new(); - let event = fixture_event(); + let mut event = fixture_event(); let mut conversation = fixture_conversation(vec![]); let initial_msg_count = conversation.context.as_ref().unwrap().messages.len(); - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let actual = conversation.context.as_ref().unwrap().messages.len(); let expected = initial_msg_count; @@ -177,13 +177,13 @@ mod tests { #[tokio::test] async fn test_pending_todos_injects_reminder() { let handler = PendingTodosHandler::new(); - let event = fixture_event(); + let mut event = fixture_event(); let mut conversation = fixture_conversation(vec![ Todo::new("Fix the build").status(TodoStatus::Pending), Todo::new("Write tests").status(TodoStatus::InProgress), ]); - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let actual = conversation.context.as_ref().unwrap().messages.len(); let expected = 1; @@ -193,13 +193,13 @@ mod tests { #[tokio::test] async fn test_reminder_contains_formatted_list() { let handler = PendingTodosHandler::new(); - let event = fixture_event(); + let mut event = fixture_event(); let mut conversation = fixture_conversation(vec![ Todo::new("Fix the build").status(TodoStatus::Pending), Todo::new("Write tests").status(TodoStatus::InProgress), ]); - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let entry = &conversation.context.as_ref().unwrap().messages[0]; let actual = entry.message.content().unwrap(); @@ -210,14 +210,14 @@ mod tests { #[tokio::test] async fn test_completed_todos_not_included() { let handler = PendingTodosHandler::new(); - let event = fixture_event(); + let mut event = fixture_event(); let mut conversation = fixture_conversation(vec![ Todo::new("Completed task").status(TodoStatus::Completed), Todo::new("Cancelled task").status(TodoStatus::Cancelled), ]); let initial_msg_count = conversation.context.as_ref().unwrap().messages.len(); - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let actual = conversation.context.as_ref().unwrap().messages.len(); let expected = initial_msg_count; @@ -227,17 +227,17 @@ mod tests { #[tokio::test] async fn test_reminder_not_duplicated_for_same_todos() { let handler = PendingTodosHandler::new(); - let event = fixture_event(); + let mut event = fixture_event(); let mut conversation = fixture_conversation(vec![Todo::new("Fix the build").status(TodoStatus::Pending)]); // First call should inject a reminder - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let after_first = conversation.context.as_ref().unwrap().messages.len(); assert_eq!(after_first, 1); // Second call with the same pending todos should NOT add another reminder - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let after_second = conversation.context.as_ref().unwrap().messages.len(); assert_eq!(after_second, 1); // Still 1, no duplicate } @@ -245,14 +245,14 @@ mod tests { #[tokio::test] async fn test_reminder_added_when_todos_change() { let handler = PendingTodosHandler::new(); - let event = fixture_event(); + let mut event = fixture_event(); let mut conversation = fixture_conversation(vec![ Todo::new("Fix the build").status(TodoStatus::Pending), Todo::new("Write tests").status(TodoStatus::InProgress), ]); // First call should inject a reminder - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let after_first = conversation.context.as_ref().unwrap().messages.len(); assert_eq!(after_first, 1); @@ -265,7 +265,7 @@ mod tests { ]); // Second call with different pending todos should add a new reminder - handler.handle(&event, &mut conversation).await.unwrap(); + handler.handle(&mut event, &mut conversation).await.unwrap(); let after_second = conversation.context.as_ref().unwrap().messages.len(); assert_eq!(after_second, 2); // New reminder added because todos changed } From faaeae86e6994373bd1bce61e0058bdb2e288dd3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:03:32 +0000 Subject: [PATCH 49/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/app.rs | 5 ++- .../forge_app/src/hooks/user_hook_handler.rs | 38 +++++++++---------- crates/forge_app/src/orch.rs | 14 +++---- crates/forge_domain/src/user_hook_io.rs | 4 +- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 28bb856fe4..0cc8aa1bea 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -9,7 +9,10 @@ use forge_stream::MpscStream; use crate::apply_tunable_parameters::ApplyTunableParameters; use crate::changed_files::ChangedFiles; use crate::dto::ToolsOverview; -use crate::hooks::{CompactionHandler, DoomLoopDetector, PendingTodosHandler, TitleGenerationHandler, TracingHandler, UserHookHandler}; +use crate::hooks::{ + CompactionHandler, DoomLoopDetector, PendingTodosHandler, TitleGenerationHandler, + TracingHandler, UserHookHandler, +}; use crate::init_conversation_metrics::InitConversationMetrics; use crate::orch::Orchestrator; use crate::services::{ diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index cb8b1e4c65..0ef4e31543 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -129,7 +129,10 @@ impl UserHookHandler { Ok(json) => json, Err(e) => { warn!(error = %e, "Failed to serialize hook input"); - return (Vec::new(), vec![format!("Hook input serialization failed: {e}")]); + return ( + Vec::new(), + vec![format!("Hook input serialization failed: {e}")], + ); } }; @@ -171,9 +174,7 @@ impl UserHookHandler { error = %e, "Hook command failed to execute" ); - warnings.push(format!( - "Hook command '{command}' failed to execute: {e}" - )); + warnings.push(format!("Hook command '{command}' failed to execute: {e}")); } } } @@ -201,7 +202,6 @@ impl UserHookHandler { let reason = output.blocking_reason("Hook blocked execution"); return Some(reason); } - } None @@ -238,7 +238,6 @@ impl UserHookHandler { return PreToolUseDecision::AllowWithUpdate(output); } } - } PreToolUseDecision::Allow @@ -356,9 +355,9 @@ impl EventHandle> for UserHookH reason = reason.as_str(), "UserPromptSubmit hook blocked with feedback" ); - event.warnings.push(format!( - "UserPromptSubmit hook blocked: {reason}" - )); + event + .warnings + .push(format!("UserPromptSubmit hook blocked: {reason}")); // Signal the orchestrator to suppress this prompt entirely. return Err(anyhow::Error::from(PromptSuppressed(reason))); } @@ -516,9 +515,9 @@ impl EventHandle> for UserH reason = reason.as_str(), "PostToolUse hook blocked with feedback" ); - event.warnings.push(format!( - "{event_name}:{tool_name} hook blocked: {reason}" - )); + event + .warnings + .push(format!("{event_name}:{tool_name} hook blocked: {reason}")); } Ok(()) @@ -575,9 +574,7 @@ impl EventHandle> for UserHookHandl hook_event_name: "Stop".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::Stop { - last_assistant_message, - }, + event_data: HookEventInput::Stop { last_assistant_message }, }; let (results, stop_warnings) = self.execute_hooks(&hooks, &input).await; @@ -588,9 +585,7 @@ impl EventHandle> for UserHookHandl reason = reason.as_str(), "Stop hook blocked (warning only, no continuation)" ); - event.warnings.push(format!( - "Stop hook blocked: {reason}" - )); + event.warnings.push(format!("Stop hook blocked: {reason}")); } Ok(()) @@ -1639,7 +1634,12 @@ mod tests { // Stop hooks no longer block -- result is Ok assert!(result.is_ok()); // Warning about blocking should be present - assert!(event.warnings.iter().any(|w| w.contains("Stop hook blocked"))); + assert!( + event + .warnings + .iter() + .any(|w| w.contains("Stop hook blocked")) + ); } #[tokio::test] diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index 3765b741a3..ef120da761 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -457,14 +457,14 @@ impl> Orc if should_yield { let end_count_before = self.conversation.len(); let mut end_event = LifecycleEvent::End(EventData::new( - self.agent.clone(), - model_id.clone(), - EndPayload, - )); - self.hook - .handle(&mut end_event, &mut self.conversation) + self.agent.clone(), + model_id.clone(), + EndPayload, + )); + self.hook + .handle(&mut end_event, &mut self.conversation) .await?; - self.drain_hook_warnings(&mut end_event).await?; + self.drain_hook_warnings(&mut end_event).await?; self.services.update(self.conversation.clone()).await?; // Check if End hook added messages - if so, continue the loop if self.conversation.len() > end_count_before { diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 8af7b0444e..1b8e59521b 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -259,9 +259,7 @@ mod tests { hook_event_name: "Stop".to_string(), cwd: "/project".to_string(), session_id: None, - event_data: HookEventInput::Stop { - last_assistant_message: None, - }, + event_data: HookEventInput::Stop { last_assistant_message: None }, }; let actual = serde_json::to_value(&fixture).unwrap(); From f4b8ea01b2da843eb633ad212cb91b63af36a25f Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:27:06 -0400 Subject: [PATCH 50/64] feat(hooks): inject additional context and stop hook feedback --- crates/forge_app/src/hooks/pending_todos.rs | 2 +- .../forge_app/src/hooks/title_generation.rs | 4 +- crates/forge_app/src/hooks/tracing.rs | 2 +- .../forge_app/src/hooks/user_hook_handler.rs | 663 +++++++++++++++--- crates/forge_app/src/orch.rs | 17 +- crates/forge_domain/src/hook.rs | 13 +- crates/forge_domain/src/user_hook_io.rs | 4 + 7 files changed, 591 insertions(+), 114 deletions(-) diff --git a/crates/forge_app/src/hooks/pending_todos.rs b/crates/forge_app/src/hooks/pending_todos.rs index 08537805b2..c10b60e5ed 100644 --- a/crates/forge_app/src/hooks/pending_todos.rs +++ b/crates/forge_app/src/hooks/pending_todos.rs @@ -157,7 +157,7 @@ mod tests { } fn fixture_event() -> EventData { - EventData::new(fixture_agent(), ModelId::new("test-model"), EndPayload) + EventData::new(fixture_agent(), ModelId::new("test-model"), EndPayload::default()) } #[tokio::test] diff --git a/crates/forge_app/src/hooks/title_generation.rs b/crates/forge_app/src/hooks/title_generation.rs index a00de076b5..0c99d79495 100644 --- a/crates/forge_app/src/hooks/title_generation.rs +++ b/crates/forge_app/src/hooks/title_generation.rs @@ -299,7 +299,7 @@ mod tests { .insert(conversation.id, TitleTask::InProgress(rx)); handler - .handle(&mut event(EndPayload), &mut conversation) + .handle(&mut event(EndPayload::default()), &mut conversation) .await .unwrap(); @@ -321,7 +321,7 @@ mod tests { .insert(conversation.id, TitleTask::InProgress(rx)); handler - .handle(&mut event(EndPayload), &mut conversation) + .handle(&mut event(EndPayload::default()), &mut conversation) .await .unwrap(); diff --git a/crates/forge_app/src/hooks/tracing.rs b/crates/forge_app/src/hooks/tracing.rs index 29c681d18b..e533cedc8e 100644 --- a/crates/forge_app/src/hooks/tracing.rs +++ b/crates/forge_app/src/hooks/tracing.rs @@ -240,7 +240,7 @@ mod tests { async fn test_tracing_handler_end_with_title() { let handler = TracingHandler::new(); let mut conversation = Conversation::generate().title(Some("Test Title".to_string())); - let mut event = EventData::new(test_agent(), test_model_id(), EndPayload); + let mut event = EventData::new(test_agent(), test_model_id(), EndPayload::default()); // Should log debug message with title handler.handle(&mut event, &mut conversation).await.unwrap(); diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 0ef4e31543..bbc0a190f4 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -5,9 +5,9 @@ use std::time::Duration; use async_trait::async_trait; use forge_config::{UserHookConfig, UserHookEntry, UserHookEventName, UserHookMatcherGroup}; use forge_domain::{ - Conversation, EndPayload, EventData, EventHandle, HookEventInput, HookExecutionResult, - HookInput, HookOutput, PromptSuppressed, RequestPayload, ResponsePayload, Role, StartPayload, - ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, + ContextMessage, Conversation, EndPayload, EventData, EventHandle, HookEventInput, + HookExecutionResult, HookInput, HookOutput, PromptSuppressed, RequestPayload, ResponsePayload, + Role, StartPayload, ToolCallArguments, ToolcallEndPayload, ToolcallStartPayload, }; use regex::Regex; use serde_json::Value; @@ -117,11 +117,12 @@ impl UserHookHandler { /// Executes a list of hook entries and returns their results along with /// any warnings for commands that failed to execute. + /// Each result is paired with the command string that produced it. async fn execute_hooks( &self, hooks: &[&UserHookEntry], input: &HookInput, - ) -> (Vec, Vec) + ) -> (Vec<(String, HookExecutionResult)>, Vec) where I: HookCommandService, { @@ -166,7 +167,7 @@ impl UserHookHandler { "Hook command '{command}' returned non-blocking error: {detail}" )); } - results.push(result); + results.push((command.clone(), result)); } Err(e) => { warn!( @@ -183,16 +184,17 @@ impl UserHookHandler { (results, warnings) } - /// Processes hook results, returning a blocking reason if any hook blocked. - fn process_results(results: &[HookExecutionResult]) -> Option { - for result in results { + /// Processes hook results, returning the blocking command and reason if + /// any hook blocked. + fn process_results(results: &[(String, HookExecutionResult)]) -> Option<(String, String)> { + for (command, result) in results { // Exit code 2 = blocking error if result.is_blocking_exit() { let message = result .blocking_message() .unwrap_or("Hook blocked execution") .to_string(); - return Some(message); + return Some((command.clone(), message)); } // Exit code 0 = check stdout for JSON decisions @@ -200,16 +202,67 @@ impl UserHookHandler { && output.is_blocking() { let reason = output.blocking_reason("Hook blocked execution"); - return Some(reason); + return Some((command.clone(), reason)); } } None } + /// Collects `additionalContext` strings from all successful hook results, + /// paired with the command that produced them. + fn collect_additional_context( + results: &[(String, HookExecutionResult)], + ) -> Vec<(String, String)> { + let mut contexts = Vec::new(); + for (command, result) in results { + if let Some(output) = result.parse_output() { + if let Some(ctx) = &output.additional_context { + if !ctx.trim().is_empty() { + contexts.push((command.clone(), ctx.clone())); + } + } + } + } + contexts + } + + /// Injects collected `additionalContext` into the conversation as a plain + /// text user message. The format matches Claude Code's transcript format: + /// ```text + /// {event_name} hook additional context: + /// [{command}]: {context} + /// ``` + /// This avoids XML-like tags that LLMs may treat as prompt injection. + fn inject_additional_context( + conversation: &mut Conversation, + event_name: &str, + contexts: &[(String, String)], + ) { + if contexts.is_empty() { + return; + } + if let Some(ctx) = conversation.context.as_mut() { + let mut lines = vec![format!("{event_name} hook additional context:")]; + for (command, context) in contexts { + lines.push(format!("[{command}]: {context}")); + } + let content = lines.join("\n"); + ctx.messages + .push(ContextMessage::user(content, None).into()); + debug!( + event_name = event_name, + context_count = contexts.len(), + "Injected additional context from hook into conversation" + ); + } + } + /// Processes PreToolUse results, extracting updated input if present. - fn process_pre_tool_use_output(results: &[HookExecutionResult]) -> PreToolUseDecision { - for result in results { + fn process_pre_tool_use_output( + results: &[(String, HookExecutionResult)], + ) -> PreToolUseDecision { + for (_command, result) in results { // Exit code 2 = blocking error if result.is_blocking_exit() { let message = result @@ -261,7 +314,7 @@ impl EventHandle> for UserHookHan async fn handle( &self, event: &mut EventData, - _conversation: &mut Conversation, + conversation: &mut Conversation, ) -> anyhow::Result<()> { if !self.has_hooks(&UserHookEventName::SessionStart) { return Ok(()); @@ -284,18 +337,8 @@ impl EventHandle> for UserHookHan let (results, warnings) = self.execute_hooks(&hooks, &input).await; event.warnings.extend(warnings); - // FIXME: SessionStart hooks can provide additional context but not block; - // additional_context is detected here but never injected into the conversation. - for result in &results { - if let Some(output) = result.parse_output() - && let Some(context) = &output.additional_context - { - debug!( - context_len = context.len(), - "SessionStart hook provided additional context" - ); - } - } + let contexts = Self::collect_additional_context(&results); + Self::inject_additional_context(conversation, "SessionStart", &contexts); Ok(()) } @@ -350,8 +393,9 @@ impl EventHandle> for UserHookH let (results, warnings) = self.execute_hooks(&hooks, &input).await; event.warnings.extend(warnings); - if let Some(reason) = Self::process_results(&results) { + if let Some((command, reason)) = Self::process_results(&results) { debug!( + command = command.as_str(), reason = reason.as_str(), "UserPromptSubmit hook blocked with feedback" ); @@ -362,6 +406,9 @@ impl EventHandle> for UserHookH return Err(anyhow::Error::from(PromptSuppressed(reason))); } + let contexts = Self::collect_additional_context(&results); + Self::inject_additional_context(conversation, "UserPromptSubmit", &contexts); + Ok(()) } } @@ -383,7 +430,7 @@ impl EventHandle> for Use async fn handle( &self, event: &mut EventData, - _conversation: &mut Conversation, + conversation: &mut Conversation, ) -> anyhow::Result<()> { if !self.has_hooks(&UserHookEventName::PreToolUse) { return Ok(()); @@ -422,6 +469,10 @@ impl EventHandle> for Use let (results, warnings) = self.execute_hooks(&hooks, &input).await; event.warnings.extend(warnings); + + let contexts = Self::collect_additional_context(&results); + Self::inject_additional_context(conversation, "PreToolUse", &contexts); + let decision = Self::process_pre_tool_use_output(&results); match decision { @@ -461,7 +512,7 @@ impl EventHandle> for UserH async fn handle( &self, event: &mut EventData, - _conversation: &mut Conversation, + conversation: &mut Conversation, ) -> anyhow::Result<()> { let is_error = event.payload.result.is_error(); let event_name = if is_error { @@ -507,11 +558,15 @@ impl EventHandle> for UserH let (results, warnings) = self.execute_hooks(&hooks, &input).await; event.warnings.extend(warnings); + let contexts = Self::collect_additional_context(&results); + Self::inject_additional_context(conversation, &event_name.to_string(), &contexts); + // PostToolUse can provide feedback via blocking - if let Some(reason) = Self::process_results(&results) { + if let Some((command, reason)) = Self::process_results(&results) { debug!( tool_name = tool_name.as_str(), event = %event_name, + command = command.as_str(), reason = reason.as_str(), "PostToolUse hook blocked with feedback" ); @@ -560,6 +615,8 @@ impl EventHandle> for UserHookHandl return Ok(()); } + let stop_hook_active = event.payload.stop_hook_active; + // Extract the last assistant message text for the Stop hook payload. let last_assistant_message = conversation.context.as_ref().and_then(|ctx| { ctx.messages @@ -574,18 +631,38 @@ impl EventHandle> for UserHookHandl hook_event_name: "Stop".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::Stop { last_assistant_message }, + event_data: HookEventInput::Stop { + stop_hook_active, + last_assistant_message }, }; let (results, stop_warnings) = self.execute_hooks(&hooks, &input).await; event.warnings.extend(stop_warnings); - if let Some(reason) = Self::process_results(&results) { + let contexts = Self::collect_additional_context(&results); + Self::inject_additional_context(conversation, "Stop", &contexts); + + if let Some((command, reason)) = Self::process_results(&results) { debug!( + command = command.as_str(), reason = reason.as_str(), - "Stop hook blocked (warning only, no continuation)" + stop_hook_active = stop_hook_active, + "Stop hook blocked, injecting feedback for continuation" ); - event.warnings.push(format!("Stop hook blocked: {reason}")); + // Inject the blocking reason as a conversation message. The + // orchestrator detects that conversation.len() increased and + // resets should_yield to false, causing another LLM turn. + // This matches Claude Code's stop-hook continuation behavior. + if let Some(ctx) = conversation.context.as_mut() { + let content = format!( + "Stop hook feedback:\n[{command}]: {reason}" + ); + ctx.messages + .push(ContextMessage::user(content, None).into()); + } + // Mark the next End invocation as stop_hook_active so hook + // scripts can detect re-entrancy and avoid infinite loops. + event.payload.stop_hook_active = true; } Ok(()) @@ -729,22 +806,22 @@ mod tests { #[test] fn test_process_pre_tool_use_output_allow_on_success() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: String::new(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Allow)); } #[test] fn test_process_pre_tool_use_output_block_on_exit_2() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(2), stdout: String::new(), stderr: "Blocked: dangerous command".to_string(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!( matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("dangerous command")) @@ -753,68 +830,68 @@ mod tests { #[test] fn test_process_pre_tool_use_output_block_on_deny() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"permissionDecision": "deny", "reason": "Not allowed"}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Not allowed")); } #[test] fn test_process_pre_tool_use_output_block_on_decision() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"decision": "block", "reason": "Blocked by policy"}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Blocked by policy")); } #[test] fn test_process_pre_tool_use_output_non_blocking_error_allows() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(1), stdout: String::new(), stderr: "some error".to_string(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Allow)); } #[test] fn test_process_results_no_blocking() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: String::new(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_results(&results); assert!(actual.is_none()); } #[test] fn test_process_results_blocking_exit_code() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(2), stdout: String::new(), stderr: "stop reason".to_string(), - }]; + })]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some("stop reason".to_string())); + assert_eq!(actual, Some(("test-cmd".to_string(), "stop reason".to_string()))); } #[test] fn test_process_results_blocking_json_decision() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"decision": "block", "reason": "keep going"}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some("keep going".to_string())); + assert_eq!(actual, Some(("test-cmd".to_string(), "keep going".to_string()))); } #[test] @@ -837,11 +914,11 @@ mod tests { fn test_process_pre_tool_use_output_allow_with_update_detected() { // A hook that returns updatedInput should produce AllowWithUpdate with the // correct updated_input value. - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("echo safe"))]); @@ -919,11 +996,11 @@ mod tests { // When HookOutput has updated_input = None (e.g. only // `{"permissionDecision": "allow"}`), AllowWithUpdate should not // overwrite the original arguments. - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"permissionDecision": "allow"}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); // permissionDecision "allow" with no updatedInput => plain Allow assert!(matches!(actual, PreToolUseDecision::Allow)); @@ -932,11 +1009,11 @@ mod tests { #[test] fn test_allow_with_update_empty_object() { // updatedInput is an empty object — still a valid update. - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"updatedInput": {}}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = serde_json::Map::new(); assert!( @@ -947,11 +1024,11 @@ mod tests { #[test] fn test_allow_with_update_complex_nested_input() { // updatedInput with nested objects and arrays. - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"updatedInput": {"file_path": "/safe/path", "options": {"recursive": true, "depth": 3}, "tags": ["a", "b"]}}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = serde_json::Map::from_iter([ ("file_path".to_string(), serde_json::json!("/safe/path")), @@ -970,11 +1047,11 @@ mod tests { fn test_block_takes_priority_over_updated_input() { // If a hook returns both decision=block AND updatedInput, the block // must win because blocking is checked before updatedInput. - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"decision": "block", "reason": "nope", "updatedInput": {"command": "echo safe"}}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "nope")); } @@ -982,11 +1059,11 @@ mod tests { #[test] fn test_deny_takes_priority_over_updated_input() { // permissionDecision=deny should block even if updatedInput is present. - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"permissionDecision": "deny", "reason": "forbidden", "updatedInput": {"command": "echo safe"}}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "forbidden")); } @@ -994,11 +1071,11 @@ mod tests { #[test] fn test_exit_code_2_blocks_even_with_updated_input_in_stdout() { // Exit code 2 is a hard block regardless of stdout content. - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(2), stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), stderr: "hard block".to_string(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("hard block"))); } @@ -1008,16 +1085,16 @@ mod tests { // When multiple hooks run and the first returns updatedInput, that // result is used (iteration stops at first non-Allow decision). let results = vec![ - HookExecutionResult { + ("test-cmd-1".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"updatedInput": {"command": "first"}}"#.to_string(), stderr: String::new(), - }, - HookExecutionResult { + }), + ("test-cmd-2".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"updatedInput": {"command": "second"}}"#.to_string(), stderr: String::new(), - }, + }), ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = @@ -1032,16 +1109,16 @@ mod tests { // A block from an earlier hook prevents a later hook's updatedInput // from being applied. let results = vec![ - HookExecutionResult { + ("test-cmd-1".to_string(), HookExecutionResult { exit_code: Some(2), stdout: String::new(), stderr: "blocked first".to_string(), - }, - HookExecutionResult { + }), + ("test-cmd-2".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), stderr: String::new(), - }, + }), ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("blocked first"))); @@ -1052,16 +1129,16 @@ mod tests { // A non-blocking error (exit 1) from the first hook is logged but // doesn't prevent a subsequent hook from returning updatedInput. let results = vec![ - HookExecutionResult { + ("test-cmd-1".to_string(), HookExecutionResult { exit_code: Some(1), stdout: String::new(), stderr: "warning".to_string(), - }, - HookExecutionResult { + }), + ("test-cmd-2".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), stderr: String::new(), - }, + }), ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = @@ -1251,47 +1328,47 @@ mod tests { #[test] fn test_process_results_blocking_continue_false() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"continue": false, "stopReason": "task complete"}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some("task complete".to_string())); + assert_eq!(actual, Some(("test-cmd".to_string(), "task complete".to_string()))); } #[test] fn test_process_pre_tool_use_output_block_on_continue_false() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"continue": false, "stopReason": "no more tools"}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "no more tools")); } #[test] fn test_process_results_stop_reason_fallback() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"decision": "block", "stopReason": "fallback reason"}"#.to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some("fallback reason".to_string())); + assert_eq!(actual, Some(("test-cmd".to_string(), "fallback reason".to_string()))); } #[test] fn test_process_results_reason_over_stop_reason() { - let results = vec![HookExecutionResult { + let results = vec![("test-cmd".to_string(), HookExecutionResult { exit_code: Some(0), stdout: r#"{"decision": "block", "reason": "primary", "stopReason": "secondary"}"# .to_string(), stderr: String::new(), - }]; + })]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some("primary".to_string())); + assert_eq!(actual, Some(("test-cmd".to_string(), "primary".to_string()))); } // ========================================================================= @@ -1524,7 +1601,7 @@ mod tests { } // ========================================================================= - // Stop hook tests: Stop hooks fire, collect warnings, never block + // Stop hook tests: Stop hooks fire and inject feedback for continuation // ========================================================================= /// Helper: creates a UserHookHandler with Stop config and a given infra. @@ -1540,7 +1617,7 @@ mod tests { ) } - /// Helper: creates an EndPayload EventData. + /// Helper: creates an EndPayload EventData with optional stop_hook_active. fn end_event() -> EventData { use forge_domain::{Agent, ModelId, ProviderId}; let agent = Agent::new( @@ -1548,11 +1625,15 @@ mod tests { ProviderId::from("test-provider".to_string()), ModelId::new("test-model"), ); - EventData::new(agent, ModelId::new("test-model"), forge_domain::EndPayload) + EventData::new( + agent, + ModelId::new("test-model"), + forge_domain::EndPayload { stop_hook_active: false }, + ) } #[tokio::test] - async fn test_stop_hook_exit_code_2_produces_warning() { + async fn test_stop_hook_exit_code_2_injects_message_and_sets_active() { #[derive(Clone)] struct StopBlockInfra; @@ -1577,14 +1658,28 @@ mod tests { let handler = stop_handler(StopBlockInfra); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); let result = handler.handle(&mut event, &mut conversation).await; - // Stop hooks no longer block -- result is Ok + // Result is Ok (never errors) assert!(result.is_ok()); - // Warning should have been pushed to event.warnings - assert!(!event.warnings.is_empty()); - assert!(event.warnings.iter().any(|w| w.contains("keep working"))); + // A conversation message should have been injected for continuation + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count + 1); + // The injected message should contain the blocking reason + let last_msg = conversation + .context + .as_ref() + .unwrap() + .messages + .last() + .unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("keep working")); + assert!(content.contains("Stop hook feedback")); + // stop_hook_active should be set to true for the next iteration + assert!(event.payload.stop_hook_active); } #[tokio::test] @@ -1603,7 +1698,7 @@ mod tests { } #[tokio::test] - async fn test_stop_hook_json_continue_false_produces_warning() { + async fn test_stop_hook_json_continue_false_injects_message() { #[derive(Clone)] struct StopJsonBlockInfra; @@ -1628,24 +1723,26 @@ mod tests { let handler = stop_handler(StopJsonBlockInfra); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); let result = handler.handle(&mut event, &mut conversation).await; - // Stop hooks no longer block -- result is Ok + // Result is Ok (never errors) assert!(result.is_ok()); - // Warning about blocking should be present + // A conversation message should have been injected + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count + 1); + // stop_hook_active should be set to true assert!( event - .warnings - .iter() - .any(|w| w.contains("Stop hook blocked")) + .payload.stop_hook_active ); } #[tokio::test] async fn test_session_end_and_stop_hooks_both_fire() { - // Both SessionEnd and Stop hooks should execute. Stop hooks produce - // warnings but never block. + // Both SessionEnd and Stop hooks should execute. Stop hooks inject + // messages for continuation when they block. use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; @@ -1664,7 +1761,7 @@ mod tests { _: HashMap, ) -> anyhow::Result { self.call_count.fetch_add(1, AtomicOrdering::SeqCst); - // Return exit 2 (would have been "blocking" before) + // Return exit 2 (blocking) Ok(forge_domain::CommandOutput { command, exit_code: Some(2), @@ -1694,11 +1791,132 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; - // No longer blocks -- result is Ok + // Result is Ok assert!(result.is_ok()); // Both SessionEnd AND Stop hooks should have been called (2 total) let actual = call_count.load(AtomicOrdering::SeqCst); assert_eq!(actual, 2); + // Stop hook blocked, so stop_hook_active should be true + assert!(event.payload.stop_hook_active); + } + + #[tokio::test] + async fn test_stop_hook_active_true_passed_to_hook_input() { + // When stop_hook_active is true (re-entrant call), the hook should + // receive it in its JSON input. + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct CapturingInfra { + captured_input: Arc>>, + } + + #[async_trait::async_trait] + impl HookCommandService for CapturingInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + input: String, + _: HashMap, + ) -> anyhow::Result { + *self.captured_input.lock().unwrap() = Some(input); + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }) + } + } + + let captured = Arc::new(Mutex::new(None)); + let handler = stop_handler(CapturingInfra { captured_input: captured.clone() }); + // Create event with stop_hook_active = true (simulating re-entrant call) + let mut event = { + use forge_domain::{Agent, ModelId, ProviderId}; + let agent = Agent::new( + "test-agent", + ProviderId::from("test-provider".to_string()), + ModelId::new("test-model"), + ); + EventData::new( + agent, + ModelId::new("test-model"), + forge_domain::EndPayload { stop_hook_active: true }, + ) + }; + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + assert!(result.is_ok()); + + // Verify the hook received stop_hook_active = true in its JSON input + let input_json = captured.lock().unwrap().clone().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&input_json).unwrap(); + assert_eq!(parsed["stop_hook_active"], serde_json::Value::Bool(true)); + } + + #[tokio::test] + async fn test_stop_hook_allow_does_not_inject_message() { + // When a Stop hook allows the stop (exit 0, no blocking JSON), no + // message should be injected and stop_hook_active should remain false. + let handler = stop_handler(NullInfra); + let mut event = end_event(); + let mut conversation = conversation_with_user_msg("hello"); + let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); + + let result = handler.handle(&mut event, &mut conversation).await; + + assert!(result.is_ok()); + // No message injected + let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_msg_count, original_msg_count); + // stop_hook_active should remain false + assert!(!event.payload.stop_hook_active); + } + + #[tokio::test] + async fn test_stop_hook_active_false_on_initial_call() { + // On the first call, stop_hook_active should be false in the JSON input. + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct CapturingInfra2 { + captured_input: Arc>>, + } + + #[async_trait::async_trait] + impl HookCommandService for CapturingInfra2 { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + input: String, + _: HashMap, + ) -> anyhow::Result { + *self.captured_input.lock().unwrap() = Some(input); + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }) + } + } + + let captured = Arc::new(Mutex::new(None)); + let handler = stop_handler(CapturingInfra2 { captured_input: captured.clone() }); + let mut event = end_event(); // stop_hook_active defaults to false + let mut conversation = conversation_with_user_msg("hello"); + + let result = handler.handle(&mut event, &mut conversation).await; + assert!(result.is_ok()); + + // Verify stop_hook_active is false in the JSON + let input_json = captured.lock().unwrap().clone().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&input_json).unwrap(); + assert_eq!(parsed["stop_hook_active"], serde_json::Value::Bool(false)); } // ========================================================================= @@ -1943,4 +2161,237 @@ mod tests { assert_eq!(event.warnings.len(), 1); assert!(event.warnings[0].contains("shell")); } + + // ========================================================================= + // Tests: additionalContext injection + // ========================================================================= + + /// Infra that returns exit 0 with `additionalContext` in JSON output. + #[derive(Clone)] + struct AdditionalContextInfra; + + #[async_trait::async_trait] + impl HookCommandService for AdditionalContextInfra { + async fn execute_command_with_input( + &self, + command: String, + _: PathBuf, + _: String, + _: HashMap, + ) -> anyhow::Result { + Ok(forge_domain::CommandOutput { + command, + exit_code: Some(0), + stdout: r#"{"additionalContext": "Remember to follow coding standards"}"# + .to_string(), + stderr: String::new(), + }) + } + } + + #[tokio::test] + async fn test_session_start_injects_additional_context() { + let json = + r#"{"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + let handler = UserHookHandler::new( + AdditionalContextInfra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-ctx".to_string(), + ); + + let agent = forge_domain::Agent::new( + "test-agent", + forge_domain::ProviderId::from("test-provider".to_string()), + forge_domain::ModelId::new("test-model"), + ); + let mut event = EventData::new( + agent, + forge_domain::ModelId::new("test-model"), + forge_domain::StartPayload, + ); + let mut conversation = conversation_with_user_msg("hello"); + let original_count = conversation.context.as_ref().unwrap().messages.len(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + let actual_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_count, original_count + 1); + + let last_msg = conversation + .context + .as_ref() + .unwrap() + .messages + .last() + .unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("SessionStart hook additional context")); + assert!(content.contains("Remember to follow coding standards")); + } + + #[tokio::test] + async fn test_user_prompt_submit_injects_additional_context() { + let handler = UserHookHandler::new( + AdditionalContextInfra, + BTreeMap::new(), + serde_json::from_str( + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ) + .unwrap(), + PathBuf::from("/tmp"), + "sess-ctx".to_string(), + ); + + let mut event = request_event(0); + let mut conversation = conversation_with_user_msg("test prompt"); + let original_count = conversation.context.as_ref().unwrap().messages.len(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + let actual_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_count, original_count + 1); + + let last_msg = conversation + .context + .as_ref() + .unwrap() + .messages + .last() + .unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("UserPromptSubmit hook additional context")); + assert!(content.contains("Remember to follow coding standards")); + } + + #[tokio::test] + async fn test_pre_tool_use_injects_additional_context() { + let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let config: UserHookConfig = serde_json::from_str(json).unwrap(); + let handler = UserHookHandler::new( + AdditionalContextInfra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-ctx".to_string(), + ); + + let agent = forge_domain::Agent::new( + "test-agent", + forge_domain::ProviderId::from("test-provider".to_string()), + forge_domain::ModelId::new("test-model"), + ); + let tool_call = forge_domain::ToolCallFull::new("shell") + .arguments(forge_domain::ToolCallArguments::from_json(r#"{"command": "ls"}"#)); + let mut event = EventData::new( + agent, + forge_domain::ModelId::new("test-model"), + forge_domain::ToolcallStartPayload::new(tool_call), + ); + let mut conversation = conversation_with_user_msg("hello"); + let original_count = conversation.context.as_ref().unwrap().messages.len(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + let actual_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_count, original_count + 1); + + let last_msg = conversation + .context + .as_ref() + .unwrap() + .messages + .last() + .unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("PreToolUse hook additional context")); + assert!(content.contains("Remember to follow coding standards")); + } + + #[tokio::test] + async fn test_post_tool_use_injects_additional_context() { + let handler = post_tool_use_handler(AdditionalContextInfra); + let mut event = toolcall_end_event("shell", false); + let mut conversation = conversation_with_user_msg("hello"); + let original_count = conversation.context.as_ref().unwrap().messages.len(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + let actual_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_count, original_count + 1); + + let last_msg = conversation + .context + .as_ref() + .unwrap() + .messages + .last() + .unwrap(); + let content = last_msg.content().unwrap(); + assert!(content.contains("PostToolUse hook additional context")); + assert!(content.contains("Remember to follow coding standards")); + } + + #[tokio::test] + async fn test_no_additional_context_when_empty() { + // NullInfra returns empty stdout => no additionalContext + let handler = post_tool_use_handler(NullInfra); + let mut event = toolcall_end_event("shell", false); + let mut conversation = conversation_with_user_msg("hello"); + let original_count = conversation.context.as_ref().unwrap().messages.len(); + + handler.handle(&mut event, &mut conversation).await.unwrap(); + + let actual_count = conversation.context.as_ref().unwrap().messages.len(); + assert_eq!(actual_count, original_count); + } + + #[test] + fn test_collect_additional_context_from_results() { + let results = vec![ + ("test-cmd".to_string(), HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": "first context"}"#.to_string(), + stderr: String::new(), + }), + ("test-cmd".to_string(), HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": "second context"}"#.to_string(), + stderr: String::new(), + }), + ]; + let actual = UserHookHandler::::collect_additional_context(&results); + assert_eq!(actual, vec![("test-cmd".to_string(), "first context".to_string()), ("test-cmd".to_string(), "second context".to_string())]); + } + + #[test] + fn test_collect_additional_context_skips_empty() { + let results = vec![ + ("test-cmd".to_string(), HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": ""}"#.to_string(), + stderr: String::new(), + }), + ("test-cmd".to_string(), HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": " "}"#.to_string(), + stderr: String::new(), + }), + ]; + let actual = UserHookHandler::::collect_additional_context(&results); + assert!(actual.is_empty()); + } + + #[test] + fn test_collect_additional_context_skips_non_success() { + let results = vec![("test-cmd".to_string(), HookExecutionResult { + exit_code: Some(1), + stdout: r#"{"additionalContext": "should not appear"}"#.to_string(), + stderr: String::new(), + })]; + let actual = UserHookHandler::::collect_additional_context(&results); + assert!(actual.is_empty()); + } } diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index ef120da761..cf8430beb9 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -284,6 +284,11 @@ impl> Orc let mut request_count = 0; + // Tracks whether a Stop hook forced continuation. Passed to the + // next EndPayload so hook scripts can detect re-entrancy and + // avoid infinite loops (matches Claude Code's `stop_hook_active`). + let mut stop_hook_active = false; + // Retrieve the number of requests allowed per tick. let max_requests_per_turn = self.agent.max_requests_per_turn; let tool_context = @@ -459,7 +464,7 @@ impl> Orc let mut end_event = LifecycleEvent::End(EventData::new( self.agent.clone(), model_id.clone(), - EndPayload, + EndPayload { stop_hook_active }, )); self.hook .handle(&mut end_event, &mut self.conversation) @@ -468,11 +473,19 @@ impl> Orc self.services.update(self.conversation.clone()).await?; // Check if End hook added messages - if so, continue the loop if self.conversation.len() > end_count_before { - // End hook added messages, sync context and continue + // End hook added messages, sync context and continue. + // Propagate stop_hook_active from the event payload so the + // next iteration knows a Stop hook caused this continuation. + if let LifecycleEvent::End(ref data) = end_event { + stop_hook_active = data.payload.stop_hook_active; + } if let Some(updated_context) = &self.conversation.context { context = updated_context.clone(); } should_yield = false; + } else { + // No continuation -- reset for next user turn. + stop_hook_active = false; } } } diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index 53388652b7..9f98b2c688 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -32,9 +32,18 @@ impl EventData

{ #[derive(Debug, PartialEq, Clone, Default)] pub struct StartPayload; -/// Payload for the End event +/// Payload for the End event. +/// +/// Carries `stop_hook_active` to signal whether the current End event was +/// triggered by a Stop hook forcing continuation (matching Claude Code's +/// `stop_hook_active` field). When `true`, hook scripts should allow the +/// agent to stop to prevent infinite loops. #[derive(Debug, PartialEq, Clone, Default)] -pub struct EndPayload; +pub struct EndPayload { + /// Whether a Stop hook caused this continuation. Sent to hook scripts + /// as `stop_hook_active` so they can break re-entrant loops. + pub stop_hook_active: bool, +} /// Payload for the Request event #[derive(Debug, PartialEq, Clone, Setters)] diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 1b8e59521b..5929fa03f0 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -58,6 +58,10 @@ pub enum HookEventInput { }, /// Input for Stop events. Stop { + /// Whether a previous Stop hook caused this continuation. Hook scripts + /// should check this to prevent infinite loops. + #[serde(default)] + stop_hook_active: bool, /// The last assistant message text before the stop event. #[serde(default, skip_serializing_if = "Option::is_none")] last_assistant_message: Option, From 9582f9c0b1e59788906b5cf5ceff2e08777979eb Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:42:47 -0400 Subject: [PATCH 51/64] test(hooks): use default end payload and add stop flag to fixtures --- crates/forge_domain/src/hook.rs | 10 +++++----- crates/forge_domain/src/user_hook_io.rs | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index 9f98b2c688..1d2c1e7d1b 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -552,7 +552,7 @@ mod tests { .unwrap(); // Test End event hook.handle( - &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), + &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), &mut conversation, ) .await @@ -577,7 +577,7 @@ mod tests { ); assert_eq!( handled[1], - LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)) + LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())) ); assert_eq!( handled[2], @@ -668,7 +668,7 @@ mod tests { let all_events = vec![ LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), - LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), + LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), LifecycleEvent::Request(EventData::new( test_agent(), test_model_id(), @@ -902,7 +902,7 @@ mod tests { // Test End event combined .handle( - &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), + &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), &mut conversation, ) .await @@ -1139,7 +1139,7 @@ mod tests { assert_eq!(*start_title.lock().unwrap(), Some("Started".to_string())); hook.handle( - &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)), + &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), &mut conversation, ) .await diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 5929fa03f0..3c509128d4 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -263,7 +263,7 @@ mod tests { hook_event_name: "Stop".to_string(), cwd: "/project".to_string(), session_id: None, - event_data: HookEventInput::Stop { last_assistant_message: None }, + event_data: HookEventInput::Stop { stop_hook_active: false, last_assistant_message: None }, }; let actual = serde_json::to_value(&fixture).unwrap(); @@ -279,6 +279,7 @@ mod tests { cwd: "/project".to_string(), session_id: Some("sess-456".to_string()), event_data: HookEventInput::Stop { + stop_hook_active: false, last_assistant_message: Some("Here is the result.".to_string()), }, }; From b9c0cceb7b6fa03da7c03ff2eb470c8d710cf7b5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:44:52 +0000 Subject: [PATCH 52/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/pending_todos.rs | 6 +- .../forge_app/src/hooks/user_hook_handler.rs | 415 +++++++++++------- crates/forge_domain/src/hook.rs | 30 +- crates/forge_domain/src/user_hook_io.rs | 5 +- 4 files changed, 288 insertions(+), 168 deletions(-) diff --git a/crates/forge_app/src/hooks/pending_todos.rs b/crates/forge_app/src/hooks/pending_todos.rs index c10b60e5ed..52f43370c8 100644 --- a/crates/forge_app/src/hooks/pending_todos.rs +++ b/crates/forge_app/src/hooks/pending_todos.rs @@ -157,7 +157,11 @@ mod tests { } fn fixture_event() -> EventData { - EventData::new(fixture_agent(), ModelId::new("test-model"), EndPayload::default()) + EventData::new( + fixture_agent(), + ModelId::new("test-model"), + EndPayload::default(), + ) } #[tokio::test] diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index bbc0a190f4..5b893330b9 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -216,13 +216,11 @@ impl UserHookHandler { ) -> Vec<(String, String)> { let mut contexts = Vec::new(); for (command, result) in results { - if let Some(output) = result.parse_output() { - if let Some(ctx) = &output.additional_context { - if !ctx.trim().is_empty() { + if let Some(output) = result.parse_output() + && let Some(ctx) = &output.additional_context + && !ctx.trim().is_empty() { contexts.push((command.clone(), ctx.clone())); } - } - } } contexts } @@ -631,9 +629,7 @@ impl EventHandle> for UserHookHandl hook_event_name: "Stop".to_string(), cwd: self.cwd.to_string_lossy().to_string(), session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::Stop { - stop_hook_active, - last_assistant_message }, + event_data: HookEventInput::Stop { stop_hook_active, last_assistant_message }, }; let (results, stop_warnings) = self.execute_hooks(&hooks, &input).await; @@ -654,9 +650,7 @@ impl EventHandle> for UserHookHandl // resets should_yield to false, causing another LLM turn. // This matches Claude Code's stop-hook continuation behavior. if let Some(ctx) = conversation.context.as_mut() { - let content = format!( - "Stop hook feedback:\n[{command}]: {reason}" - ); + let content = format!("Stop hook feedback:\n[{command}]: {reason}"); ctx.messages .push(ContextMessage::user(content, None).into()); } @@ -806,22 +800,28 @@ mod tests { #[test] fn test_process_pre_tool_use_output_allow_on_success() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: String::new(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Allow)); } #[test] fn test_process_pre_tool_use_output_block_on_exit_2() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(2), - stdout: String::new(), - stderr: "Blocked: dangerous command".to_string(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(2), + stdout: String::new(), + stderr: "Blocked: dangerous command".to_string(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!( matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("dangerous command")) @@ -830,68 +830,92 @@ mod tests { #[test] fn test_process_pre_tool_use_output_block_on_deny() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"permissionDecision": "deny", "reason": "Not allowed"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"permissionDecision": "deny", "reason": "Not allowed"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Not allowed")); } #[test] fn test_process_pre_tool_use_output_block_on_decision() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"decision": "block", "reason": "Blocked by policy"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "Blocked by policy"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "Blocked by policy")); } #[test] fn test_process_pre_tool_use_output_non_blocking_error_allows() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(1), - stdout: String::new(), - stderr: "some error".to_string(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(1), + stdout: String::new(), + stderr: "some error".to_string(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Allow)); } #[test] fn test_process_results_no_blocking() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: String::new(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_results(&results); assert!(actual.is_none()); } #[test] fn test_process_results_blocking_exit_code() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(2), - stdout: String::new(), - stderr: "stop reason".to_string(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(2), + stdout: String::new(), + stderr: "stop reason".to_string(), + }, + )]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some(("test-cmd".to_string(), "stop reason".to_string()))); + assert_eq!( + actual, + Some(("test-cmd".to_string(), "stop reason".to_string())) + ); } #[test] fn test_process_results_blocking_json_decision() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"decision": "block", "reason": "keep going"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "keep going"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some(("test-cmd".to_string(), "keep going".to_string()))); + assert_eq!( + actual, + Some(("test-cmd".to_string(), "keep going".to_string())) + ); } #[test] @@ -914,11 +938,14 @@ mod tests { fn test_process_pre_tool_use_output_allow_with_update_detected() { // A hook that returns updatedInput should produce AllowWithUpdate with the // correct updated_input value. - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = serde_json::Map::from_iter([("command".to_string(), serde_json::json!("echo safe"))]); @@ -996,11 +1023,14 @@ mod tests { // When HookOutput has updated_input = None (e.g. only // `{"permissionDecision": "allow"}`), AllowWithUpdate should not // overwrite the original arguments. - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"permissionDecision": "allow"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"permissionDecision": "allow"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); // permissionDecision "allow" with no updatedInput => plain Allow assert!(matches!(actual, PreToolUseDecision::Allow)); @@ -1009,11 +1039,14 @@ mod tests { #[test] fn test_allow_with_update_empty_object() { // updatedInput is an empty object — still a valid update. - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"updatedInput": {}}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {}}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = serde_json::Map::new(); assert!( @@ -1071,11 +1104,14 @@ mod tests { #[test] fn test_exit_code_2_blocks_even_with_updated_input_in_stdout() { // Exit code 2 is a hard block regardless of stdout content. - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(2), - stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), - stderr: "hard block".to_string(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(2), + stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), + stderr: "hard block".to_string(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("hard block"))); } @@ -1085,16 +1121,22 @@ mod tests { // When multiple hooks run and the first returns updatedInput, that // result is used (iteration stops at first non-Allow decision). let results = vec![ - ("test-cmd-1".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"updatedInput": {"command": "first"}}"#.to_string(), - stderr: String::new(), - }), - ("test-cmd-2".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"updatedInput": {"command": "second"}}"#.to_string(), - stderr: String::new(), - }), + ( + "test-cmd-1".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "first"}}"#.to_string(), + stderr: String::new(), + }, + ), + ( + "test-cmd-2".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "second"}}"#.to_string(), + stderr: String::new(), + }, + ), ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = @@ -1109,16 +1151,22 @@ mod tests { // A block from an earlier hook prevents a later hook's updatedInput // from being applied. let results = vec![ - ("test-cmd-1".to_string(), HookExecutionResult { - exit_code: Some(2), - stdout: String::new(), - stderr: "blocked first".to_string(), - }), - ("test-cmd-2".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), - stderr: String::new(), - }), + ( + "test-cmd-1".to_string(), + HookExecutionResult { + exit_code: Some(2), + stdout: String::new(), + stderr: "blocked first".to_string(), + }, + ), + ( + "test-cmd-2".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), + stderr: String::new(), + }, + ), ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg.contains("blocked first"))); @@ -1129,16 +1177,22 @@ mod tests { // A non-blocking error (exit 1) from the first hook is logged but // doesn't prevent a subsequent hook from returning updatedInput. let results = vec![ - ("test-cmd-1".to_string(), HookExecutionResult { - exit_code: Some(1), - stdout: String::new(), - stderr: "warning".to_string(), - }), - ("test-cmd-2".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), - stderr: String::new(), - }), + ( + "test-cmd-1".to_string(), + HookExecutionResult { + exit_code: Some(1), + stdout: String::new(), + stderr: "warning".to_string(), + }, + ), + ( + "test-cmd-2".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"updatedInput": {"command": "safe"}}"#.to_string(), + stderr: String::new(), + }, + ), ]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); let expected_map = @@ -1328,47 +1382,68 @@ mod tests { #[test] fn test_process_results_blocking_continue_false() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"continue": false, "stopReason": "task complete"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"continue": false, "stopReason": "task complete"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some(("test-cmd".to_string(), "task complete".to_string()))); + assert_eq!( + actual, + Some(("test-cmd".to_string(), "task complete".to_string())) + ); } #[test] fn test_process_pre_tool_use_output_block_on_continue_false() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"continue": false, "stopReason": "no more tools"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"continue": false, "stopReason": "no more tools"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_pre_tool_use_output(&results); assert!(matches!(actual, PreToolUseDecision::Block(msg) if msg == "no more tools")); } #[test] fn test_process_results_stop_reason_fallback() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"decision": "block", "stopReason": "fallback reason"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "stopReason": "fallback reason"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some(("test-cmd".to_string(), "fallback reason".to_string()))); + assert_eq!( + actual, + Some(("test-cmd".to_string(), "fallback reason".to_string())) + ); } #[test] fn test_process_results_reason_over_stop_reason() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"decision": "block", "reason": "primary", "stopReason": "secondary"}"# - .to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"decision": "block", "reason": "primary", "stopReason": "secondary"}"# + .to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::process_results(&results); - assert_eq!(actual, Some(("test-cmd".to_string(), "primary".to_string()))); + assert_eq!( + actual, + Some(("test-cmd".to_string(), "primary".to_string())) + ); } // ========================================================================= @@ -1733,10 +1808,7 @@ mod tests { let actual_msg_count = conversation.context.as_ref().unwrap().messages.len(); assert_eq!(actual_msg_count, original_msg_count + 1); // stop_hook_active should be set to true - assert!( - event - .payload.stop_hook_active - ); + assert!(event.payload.stop_hook_active); } #[tokio::test] @@ -2191,8 +2263,7 @@ mod tests { #[tokio::test] async fn test_session_start_injects_additional_context() { - let json = - r#"{"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; + let json = r#"{"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); let handler = UserHookHandler::new( AdditionalContextInfra, @@ -2283,8 +2354,9 @@ mod tests { forge_domain::ProviderId::from("test-provider".to_string()), forge_domain::ModelId::new("test-model"), ); - let tool_call = forge_domain::ToolCallFull::new("shell") - .arguments(forge_domain::ToolCallArguments::from_json(r#"{"command": "ls"}"#)); + let tool_call = forge_domain::ToolCallFull::new("shell").arguments( + forge_domain::ToolCallArguments::from_json(r#"{"command": "ls"}"#), + ); let mut event = EventData::new( agent, forge_domain::ModelId::new("test-model"), @@ -2351,34 +2423,52 @@ mod tests { #[test] fn test_collect_additional_context_from_results() { let results = vec![ - ("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"additionalContext": "first context"}"#.to_string(), - stderr: String::new(), - }), - ("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"additionalContext": "second context"}"#.to_string(), - stderr: String::new(), - }), + ( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": "first context"}"#.to_string(), + stderr: String::new(), + }, + ), + ( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": "second context"}"#.to_string(), + stderr: String::new(), + }, + ), ]; let actual = UserHookHandler::::collect_additional_context(&results); - assert_eq!(actual, vec![("test-cmd".to_string(), "first context".to_string()), ("test-cmd".to_string(), "second context".to_string())]); + assert_eq!( + actual, + vec![ + ("test-cmd".to_string(), "first context".to_string()), + ("test-cmd".to_string(), "second context".to_string()) + ] + ); } #[test] fn test_collect_additional_context_skips_empty() { let results = vec![ - ("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"additionalContext": ""}"#.to_string(), - stderr: String::new(), - }), - ("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(0), - stdout: r#"{"additionalContext": " "}"#.to_string(), - stderr: String::new(), - }), + ( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": ""}"#.to_string(), + stderr: String::new(), + }, + ), + ( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(0), + stdout: r#"{"additionalContext": " "}"#.to_string(), + stderr: String::new(), + }, + ), ]; let actual = UserHookHandler::::collect_additional_context(&results); assert!(actual.is_empty()); @@ -2386,11 +2476,14 @@ mod tests { #[test] fn test_collect_additional_context_skips_non_success() { - let results = vec![("test-cmd".to_string(), HookExecutionResult { - exit_code: Some(1), - stdout: r#"{"additionalContext": "should not appear"}"#.to_string(), - stderr: String::new(), - })]; + let results = vec![( + "test-cmd".to_string(), + HookExecutionResult { + exit_code: Some(1), + stdout: r#"{"additionalContext": "should not appear"}"#.to_string(), + stderr: String::new(), + }, + )]; let actual = UserHookHandler::::collect_additional_context(&results); assert!(actual.is_empty()); } diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index 1d2c1e7d1b..e841fab3cb 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -552,7 +552,11 @@ mod tests { .unwrap(); // Test End event hook.handle( - &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), + &mut LifecycleEvent::End(EventData::new( + test_agent(), + test_model_id(), + EndPayload::default(), + )), &mut conversation, ) .await @@ -577,7 +581,11 @@ mod tests { ); assert_eq!( handled[1], - LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())) + LifecycleEvent::End(EventData::new( + test_agent(), + test_model_id(), + EndPayload::default() + )) ); assert_eq!( handled[2], @@ -668,7 +676,11 @@ mod tests { let all_events = vec![ LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)), - LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), + LifecycleEvent::End(EventData::new( + test_agent(), + test_model_id(), + EndPayload::default(), + )), LifecycleEvent::Request(EventData::new( test_agent(), test_model_id(), @@ -902,7 +914,11 @@ mod tests { // Test End event combined .handle( - &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), + &mut LifecycleEvent::End(EventData::new( + test_agent(), + test_model_id(), + EndPayload::default(), + )), &mut conversation, ) .await @@ -1139,7 +1155,11 @@ mod tests { assert_eq!(*start_title.lock().unwrap(), Some("Started".to_string())); hook.handle( - &mut LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload::default())), + &mut LifecycleEvent::End(EventData::new( + test_agent(), + test_model_id(), + EndPayload::default(), + )), &mut conversation, ) .await diff --git a/crates/forge_domain/src/user_hook_io.rs b/crates/forge_domain/src/user_hook_io.rs index 3c509128d4..15cde3d703 100644 --- a/crates/forge_domain/src/user_hook_io.rs +++ b/crates/forge_domain/src/user_hook_io.rs @@ -263,7 +263,10 @@ mod tests { hook_event_name: "Stop".to_string(), cwd: "/project".to_string(), session_id: None, - event_data: HookEventInput::Stop { stop_hook_active: false, last_assistant_message: None }, + event_data: HookEventInput::Stop { + stop_hook_active: false, + last_assistant_message: None, + }, }; let actual = serde_json::to_value(&fixture).unwrap(); From ab01d4f0ec9ec1d745640896c73a4f4709be2a96 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:46:38 +0000 Subject: [PATCH 53/64] [autofix.ci] apply automated fixes (attempt 2/3) --- crates/forge_app/src/hooks/user_hook_handler.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 5b893330b9..b8ce2998a0 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -218,9 +218,10 @@ impl UserHookHandler { for (command, result) in results { if let Some(output) = result.parse_output() && let Some(ctx) = &output.additional_context - && !ctx.trim().is_empty() { - contexts.push((command.clone(), ctx.clone())); - } + && !ctx.trim().is_empty() + { + contexts.push((command.clone(), ctx.clone())); + } } contexts } From 4bd1743feb637b6d57d993bbd7a21890ccbb3d10 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:09:53 -0400 Subject: [PATCH 54/64] feat(hooks): store posttooluse feedback for ordered injection --- .../forge_app/src/hooks/user_hook_handler.rs | 26 ++++++++++++++++--- crates/forge_app/src/orch.rs | 24 ++++++++++++++--- crates/forge_domain/src/hook.rs | 7 ++++- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index b8ce2998a0..b83f841317 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -560,15 +560,23 @@ impl EventHandle> for UserH let contexts = Self::collect_additional_context(&results); Self::inject_additional_context(conversation, &event_name.to_string(), &contexts); - // PostToolUse can provide feedback via blocking + // PostToolUse blocking: store the feedback on the event payload. + // The orchestrator reads `hook_feedback` after `append_message` and + // injects it into context at the correct position — after the tool + // result, not before it. This ensures the LLM sees the feedback in + // the right order. if let Some((command, reason)) = Self::process_results(&results) { debug!( tool_name = tool_name.as_str(), event = %event_name, command = command.as_str(), reason = reason.as_str(), - "PostToolUse hook blocked with feedback" + "PostToolUse hook blocked, storing feedback for orchestrator injection" ); + let content = format!( + "{event_name}:{tool_name} hook feedback:\n[{command}]: {reason}" + ); + event.payload.hook_feedback = Some(content.clone()); event .warnings .push(format!("{event_name}:{tool_name} hook blocked: {reason}")); @@ -2064,12 +2072,17 @@ mod tests { let result = handler.handle(&mut event, &mut conversation).await; - // PostToolUse does NOT block execution — always Ok + // PostToolUse always returns Ok assert!(result.is_ok()); - // But warning should be pushed to event.warnings + // Warning pushed to event.warnings assert_eq!(event.warnings.len(), 1); assert!(event.warnings[0].contains("sensitive data detected")); + + // Feedback stored on payload for the orchestrator to inject after append_message + let feedback = event.payload.hook_feedback.as_ref().unwrap(); + assert!(feedback.contains("hook feedback")); + assert!(feedback.contains("sensitive data detected")); } #[tokio::test] @@ -2104,6 +2117,11 @@ mod tests { assert!(result.is_ok()); assert_eq!(event.warnings.len(), 1); assert!(event.warnings[0].contains("PII detected")); + + // Feedback stored on payload for the orchestrator to inject after append_message + let feedback = event.payload.hook_feedback.as_ref().unwrap(); + assert!(feedback.contains("hook feedback")); + assert!(feedback.contains("PII detected")); } #[tokio::test] diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index cf8430beb9..3933697fb5 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -52,13 +52,15 @@ impl> Orc &self.conversation } - // Helper function to get all tool results from a vector of tool calls + // Returns tool results and any PostToolUse hook feedback messages. + // Feedback messages must be injected into context AFTER append_message + // so the LLM sees them in the correct order (after tool results). #[async_recursion] async fn execute_tool_calls( &mut self, tool_calls: &[ToolCallFull], tool_context: &ToolCallContext, - ) -> anyhow::Result> { + ) -> anyhow::Result<(Vec<(ToolCallFull, ToolResult)>, Vec)> { let task_tool_name = ToolKind::Task.name(); // Use a case-insensitive comparison since the model may send "Task" or "task". @@ -98,6 +100,7 @@ impl> Orc // and hooks). let mut other_results: Vec<(ToolCallFull, ToolResult)> = Vec::with_capacity(other_calls.len()); + let mut hook_feedbacks: Vec = Vec::new(); for tool_call in &other_calls { // Send the start notification for system tools and not agent as a tool let is_system_tool = system_tools.contains(&tool_call.name); @@ -165,6 +168,13 @@ impl> Orc .await?; self.drain_hook_warnings(&mut toolcall_end_event).await?; + // Collect PostToolUse hook feedback to inject after append_message. + if let LifecycleEvent::ToolcallEnd(ref data) = toolcall_end_event { + if let Some(feedback) = &data.payload.hook_feedback { + hook_feedbacks.push(feedback.clone()); + } + } + // Send the end notification for system tools and not agent as a tool if is_system_tool { self.send(ChatResponse::ToolCallEnd(tool_result.clone())) @@ -187,7 +197,7 @@ impl> Orc }) .collect(); - Ok(tool_call_records) + Ok((tool_call_records, hook_feedbacks)) } /// Drains any hook warnings from a lifecycle event and emits them to the @@ -374,7 +384,7 @@ impl> Orc .any(|call| ToolCatalog::should_yield(&call.name)); // Process tool calls and update context - let mut tool_call_records = self + let (mut tool_call_records, hook_feedbacks) = self .execute_tool_calls(&message.tool_calls, &tool_context) .await?; @@ -411,6 +421,12 @@ impl> Orc message.phase, ); + // Inject PostToolUse hook feedback AFTER the tool results are appended. + // This ensures the LLM sees: [tool_result] [hook_feedback], not the reverse. + for feedback in hook_feedbacks { + context.messages.push(ContextMessage::user(feedback, None).into()); + } + if self.error_tracker.limit_reached() { self.send(ChatResponse::Interrupt { reason: InterruptionReason::MaxToolFailurePerTurnLimitReached { diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index e841fab3cb..03ccdd1a6c 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -98,12 +98,17 @@ pub struct ToolcallEndPayload { pub tool_call: ToolCallFull, /// The tool result (success or failure) pub result: ToolResult, + /// Feedback message from a blocking PostToolUse hook, if any. + /// Set by the hook handler and read by the orchestrator after + /// `append_message` so the message is injected in the correct + /// position (after the tool result, not before it). + pub hook_feedback: Option, } impl ToolcallEndPayload { /// Creates a new tool call end payload pub fn new(tool_call: ToolCallFull, result: ToolResult) -> Self { - Self { tool_call, result } + Self { tool_call, result, hook_feedback: None } } } From 6222ba5c46e6521e9bb94021e7ca118c87bc274a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:11:54 +0000 Subject: [PATCH 55/64] [autofix.ci] apply automated fixes --- crates/forge_app/src/hooks/user_hook_handler.rs | 10 +++++----- crates/forge_app/src/orch.rs | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index b83f841317..074c2a2ae3 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -573,9 +573,7 @@ impl EventHandle> for UserH reason = reason.as_str(), "PostToolUse hook blocked, storing feedback for orchestrator injection" ); - let content = format!( - "{event_name}:{tool_name} hook feedback:\n[{command}]: {reason}" - ); + let content = format!("{event_name}:{tool_name} hook feedback:\n[{command}]: {reason}"); event.payload.hook_feedback = Some(content.clone()); event .warnings @@ -2079,7 +2077,8 @@ mod tests { assert_eq!(event.warnings.len(), 1); assert!(event.warnings[0].contains("sensitive data detected")); - // Feedback stored on payload for the orchestrator to inject after append_message + // Feedback stored on payload for the orchestrator to inject after + // append_message let feedback = event.payload.hook_feedback.as_ref().unwrap(); assert!(feedback.contains("hook feedback")); assert!(feedback.contains("sensitive data detected")); @@ -2118,7 +2117,8 @@ mod tests { assert_eq!(event.warnings.len(), 1); assert!(event.warnings[0].contains("PII detected")); - // Feedback stored on payload for the orchestrator to inject after append_message + // Feedback stored on payload for the orchestrator to inject after + // append_message let feedback = event.payload.hook_feedback.as_ref().unwrap(); assert!(feedback.contains("hook feedback")); assert!(feedback.contains("PII detected")); diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index 3933697fb5..cb33aedfd9 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -169,11 +169,10 @@ impl> Orc self.drain_hook_warnings(&mut toolcall_end_event).await?; // Collect PostToolUse hook feedback to inject after append_message. - if let LifecycleEvent::ToolcallEnd(ref data) = toolcall_end_event { - if let Some(feedback) = &data.payload.hook_feedback { + if let LifecycleEvent::ToolcallEnd(ref data) = toolcall_end_event + && let Some(feedback) = &data.payload.hook_feedback { hook_feedbacks.push(feedback.clone()); } - } // Send the end notification for system tools and not agent as a tool if is_system_tool { @@ -424,7 +423,9 @@ impl> Orc // Inject PostToolUse hook feedback AFTER the tool results are appended. // This ensures the LLM sees: [tool_result] [hook_feedback], not the reverse. for feedback in hook_feedbacks { - context.messages.push(ContextMessage::user(feedback, None).into()); + context + .messages + .push(ContextMessage::user(feedback, None).into()); } if self.error_tracker.limit_reached() { From e4fbbf7837c7aeed22923e369859bb1111d7c69a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:13:39 +0000 Subject: [PATCH 56/64] [autofix.ci] apply automated fixes (attempt 2/3) --- crates/forge_app/src/orch.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index cb33aedfd9..3c45d50d00 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -170,9 +170,10 @@ impl> Orc // Collect PostToolUse hook feedback to inject after append_message. if let LifecycleEvent::ToolcallEnd(ref data) = toolcall_end_event - && let Some(feedback) = &data.payload.hook_feedback { - hook_feedbacks.push(feedback.clone()); - } + && let Some(feedback) = &data.payload.hook_feedback + { + hook_feedbacks.push(feedback.clone()); + } // Send the end notification for system tools and not agent as a tool if is_system_tool { From deec13afa13e10fb6c32ad5db7ac03e0b1939c58 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod Date: Thu, 9 Apr 2026 22:22:44 -0400 Subject: [PATCH 57/64] refactor(hooks): consolidate lifecycle hook execution (#2920) --- .../forge_app/src/hooks/user_hook_executor.rs | 2 +- .../forge_app/src/hooks/user_hook_handler.rs | 679 ++++-------------- crates/forge_config/src/hooks.rs | 18 +- crates/forge_main/src/info.rs | 1 - 4 files changed, 160 insertions(+), 540 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_executor.rs b/crates/forge_app/src/hooks/user_hook_executor.rs index cf8ffcbdcc..6e203b932a 100644 --- a/crates/forge_app/src/hooks/user_hook_executor.rs +++ b/crates/forge_app/src/hooks/user_hook_executor.rs @@ -89,7 +89,7 @@ impl UserHookExecutor { debug!( command = command, exit_code = ?output.exit_code, - stdout_len = output.stdout, + stdout_len = output.stdout.len(), stderr_len = output.stderr.len(), "Hook command completed" ); diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 074c2a2ae3..3f3423ab1a 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -72,6 +72,17 @@ impl UserHookHandler { !self.config.get_groups(event).is_empty() } + /// Constructs a [`HookInput`] from the common fields stored in this + /// handler, leaving only the event-specific `event_data` to the caller. + fn build_base_input(&self, event_name: &UserHookEventName, event_data: HookEventInput) -> HookInput { + HookInput { + hook_event_name: event_name.to_string(), + cwd: self.cwd.to_string_lossy().to_string(), + session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), + event_data, + } + } + /// Finds matching hook entries for an event, filtered by the optional /// matcher regex against the given subject string. fn find_matching_hooks<'a>( @@ -184,31 +195,70 @@ impl UserHookHandler { (results, warnings) } - /// Processes hook results, returning the blocking command and reason if - /// any hook blocked. - fn process_results(results: &[(String, HookExecutionResult)]) -> Option<(String, String)> { - for (command, result) in results { - // Exit code 2 = blocking error - if result.is_blocking_exit() { - let message = result - .blocking_message() - .unwrap_or("Hook blocked execution") - .to_string(); - return Some((command.clone(), message)); - } + /// Runs matching hooks for the given event and collects results. + /// + /// This encapsulates the common lifecycle hook pattern: + /// 1. Resolve matcher groups for the event. + /// 2. Find hooks matching the optional subject. + /// 3. Execute matched hooks, collecting results and warnings. + /// 4. Extend event warnings. + /// 5. Collect and inject any `additionalContext` into the conversation. + /// + /// Returns the raw results for event-specific post-processing. + async fn run_hooks_and_collect( + &self, + event_name: &UserHookEventName, + subject: Option<&str>, + input: &HookInput, + warnings: &mut Vec, + conversation: &mut Conversation, + ) -> Vec<(String, HookExecutionResult)> + where + I: HookCommandService, + { + let groups = self.config.get_groups(event_name); + let hooks = Self::find_matching_hooks(groups, subject); - // Exit code 0 = check stdout for JSON decisions - if let Some(output) = result.parse_output() - && output.is_blocking() - { - let reason = output.blocking_reason("Hook blocked execution"); - return Some((command.clone(), reason)); - } + if hooks.is_empty() { + return Vec::new(); + } + + let (results, exec_warnings) = self.execute_hooks(&hooks, input).await; + warnings.extend(exec_warnings); + + let contexts = Self::collect_additional_context(&results); + Self::inject_additional_context(conversation, &event_name.to_string(), &contexts); + + results + } + + /// Checks a single hook result for blocking signals (exit code 2 or JSON + /// blocking decision). Returns the blocking command and reason if found. + fn check_blocking(command: &str, result: &HookExecutionResult) -> Option<(String, String)> { + if result.is_blocking_exit() { + let message = result + .blocking_message() + .unwrap_or("Hook blocked execution") + .to_string(); + return Some((command.to_string(), message)); + } + + if let Some(output) = result.parse_output() + && output.is_blocking() + { + let reason = output.blocking_reason("Hook blocked execution"); + return Some((command.to_string(), reason)); } None } + /// Processes hook results, returning the blocking command and reason if + /// any hook blocked. + fn process_results(results: &[(String, HookExecutionResult)]) -> Option<(String, String)> { + results.iter().find_map(|(cmd, result)| Self::check_blocking(cmd, result)) + } + /// Collects `additionalContext` strings from all successful hook results, /// paired with the command that produced them. fn collect_additional_context( @@ -319,25 +369,9 @@ impl EventHandle> for UserHookHan return Ok(()); } - let groups = self.config.get_groups(&UserHookEventName::SessionStart); - let hooks = Self::find_matching_hooks(groups, Some("startup")); - - if hooks.is_empty() { - return Ok(()); - } - - let input = HookInput { - hook_event_name: UserHookEventName::SessionStart.to_string(), - cwd: self.cwd.to_string_lossy().to_string(), - session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::SessionStart { source: "startup".to_string() }, - }; + let input = self.build_base_input(&UserHookEventName::SessionStart, HookEventInput::SessionStart { source: "startup".to_string() }); - let (results, warnings) = self.execute_hooks(&hooks, &input).await; - event.warnings.extend(warnings); - - let contexts = Self::collect_additional_context(&results); - Self::inject_additional_context(conversation, "SessionStart", &contexts); + self.run_hooks_and_collect(&UserHookEventName::SessionStart, Some("startup"), &input, &mut event.warnings, conversation).await; Ok(()) } @@ -361,13 +395,6 @@ impl EventHandle> for UserHookH return Ok(()); } - let groups = self.config.get_groups(&UserHookEventName::UserPromptSubmit); - let hooks = Self::find_matching_hooks(groups, None); - - if hooks.is_empty() { - return Ok(()); - } - // Extract the last user message text as the prompt sent to the hook. let prompt = conversation .context @@ -382,15 +409,9 @@ impl EventHandle> for UserHookH }) .unwrap_or_default(); - let input = HookInput { - hook_event_name: "UserPromptSubmit".to_string(), - cwd: self.cwd.to_string_lossy().to_string(), - session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::UserPromptSubmit { prompt }, - }; + let input = self.build_base_input(&UserHookEventName::UserPromptSubmit, HookEventInput::UserPromptSubmit { prompt }); - let (results, warnings) = self.execute_hooks(&hooks, &input).await; - event.warnings.extend(warnings); + let results = self.run_hooks_and_collect(&UserHookEventName::UserPromptSubmit, None, &input, &mut event.warnings, conversation).await; if let Some((command, reason)) = Self::process_results(&results) { debug!( @@ -405,9 +426,6 @@ impl EventHandle> for UserHookH return Err(anyhow::Error::from(PromptSuppressed(reason))); } - let contexts = Self::collect_additional_context(&results); - Self::inject_additional_context(conversation, "UserPromptSubmit", &contexts); - Ok(()) } } @@ -438,13 +456,8 @@ impl EventHandle> for Use // Use owned String to avoid borrow conflicts when mutating event later. let tool_name = event.payload.tool_call.name.as_str().to_string(); // FIXME: Add a tool name transformer to map tool names to Forge - // equivalents (e.g. "Bash" → "shell") so that hook configs written - let groups = self.config.get_groups(&UserHookEventName::PreToolUse); - let hooks = Self::find_matching_hooks(groups, Some(tool_name.as_str())); - - if hooks.is_empty() { - return Ok(()); - } + // equivalents (e.g. "Bash" -> "shell") so that hook matchers written + // for other coding assistants work correctly. let tool_input = serde_json::to_value(&event.payload.tool_call.arguments).unwrap_or_default(); @@ -455,22 +468,13 @@ impl EventHandle> for Use .as_ref() .map(|id| id.as_str().to_string()); - let input = HookInput { - hook_event_name: "PreToolUse".to_string(), - cwd: self.cwd.to_string_lossy().to_string(), - session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::PreToolUse { + let input = self.build_base_input(&UserHookEventName::PreToolUse, HookEventInput::PreToolUse { tool_name: tool_name.clone(), tool_input, tool_use_id, - }, - }; + }); - let (results, warnings) = self.execute_hooks(&hooks, &input).await; - event.warnings.extend(warnings); - - let contexts = Self::collect_additional_context(&results); - Self::inject_additional_context(conversation, "PreToolUse", &contexts); + let results = self.run_hooks_and_collect(&UserHookEventName::PreToolUse, Some(tool_name.as_str()), &input, &mut event.warnings, conversation).await; let decision = Self::process_pre_tool_use_output(&results); @@ -525,12 +529,6 @@ impl EventHandle> for UserH } let tool_name = event.payload.tool_call.name.as_str().to_string(); - let groups = self.config.get_groups(&event_name); - let hooks = Self::find_matching_hooks(groups, Some(&tool_name)); - - if hooks.is_empty() { - return Ok(()); - } let tool_input = serde_json::to_value(&event.payload.tool_call.arguments).unwrap_or_default(); @@ -542,23 +540,14 @@ impl EventHandle> for UserH .as_ref() .map(|id| id.as_str().to_string()); - let input = HookInput { - hook_event_name: event_name.to_string(), - cwd: self.cwd.to_string_lossy().to_string(), - session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::PostToolUse { + let input = self.build_base_input(&event_name, HookEventInput::PostToolUse { tool_name: tool_name.to_string(), tool_input, tool_response, tool_use_id, - }, - }; + }); - let (results, warnings) = self.execute_hooks(&hooks, &input).await; - event.warnings.extend(warnings); - - let contexts = Self::collect_additional_context(&results); - Self::inject_additional_context(conversation, &event_name.to_string(), &contexts); + let results = self.run_hooks_and_collect(&event_name, Some(&tool_name), &input, &mut event.warnings, conversation).await; // PostToolUse blocking: store the feedback on the event payload. // The orchestrator reads `hook_feedback` after `append_message` and @@ -593,19 +582,8 @@ impl EventHandle> for UserHookHandl ) -> anyhow::Result<()> { // Fire SessionEnd hooks if self.has_hooks(&UserHookEventName::SessionEnd) { - let groups = self.config.get_groups(&UserHookEventName::SessionEnd); - let hooks = Self::find_matching_hooks(groups, None); - - if !hooks.is_empty() { - let input = HookInput { - hook_event_name: "SessionEnd".to_string(), - cwd: self.cwd.to_string_lossy().to_string(), - session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::Empty {}, - }; - let (_, session_warnings) = self.execute_hooks(&hooks, &input).await; - event.warnings.extend(session_warnings); - } + let input = self.build_base_input(&UserHookEventName::SessionEnd, HookEventInput::Empty {}); + self.run_hooks_and_collect(&UserHookEventName::SessionEnd, None, &input, &mut event.warnings, conversation).await; } // Fire Stop hooks @@ -613,13 +591,6 @@ impl EventHandle> for UserHookHandl return Ok(()); } - let groups = self.config.get_groups(&UserHookEventName::Stop); - let hooks = Self::find_matching_hooks(groups, None); - - if hooks.is_empty() { - return Ok(()); - } - let stop_hook_active = event.payload.stop_hook_active; // Extract the last assistant message text for the Stop hook payload. @@ -632,18 +603,9 @@ impl EventHandle> for UserHookHandl .map(|s| s.to_string()) }); - let input = HookInput { - hook_event_name: "Stop".to_string(), - cwd: self.cwd.to_string_lossy().to_string(), - session_id: self.env_vars.get("FORGE_SESSION_ID").cloned(), - event_data: HookEventInput::Stop { stop_hook_active, last_assistant_message }, - }; + let input = self.build_base_input(&UserHookEventName::Stop, HookEventInput::Stop { stop_hook_active, last_assistant_message }); - let (results, stop_warnings) = self.execute_hooks(&hooks, &input).await; - event.warnings.extend(stop_warnings); - - let contexts = Self::collect_additional_context(&results); - Self::inject_additional_context(conversation, "Stop", &contexts); + let results = self.run_hooks_and_collect(&UserHookEventName::Stop, None, &input, &mut event.warnings, conversation).await; if let Some((command, reason)) = Self::process_results(&results) { debug!( @@ -713,6 +675,53 @@ mod tests { ) } + /// Configurable stub that returns a fixed `CommandOutput` for every call. + /// Replaces all single-purpose inline stubs (BlockExit2, JsonBlockInfra, + /// ContinueFalseInfra, Exit1Infra, StopBlockInfra, etc.). + #[derive(Clone)] + struct StubInfra { + output: forge_domain::CommandOutput, + } + + impl StubInfra { + fn new(exit_code: Option, stdout: &str, stderr: &str) -> Self { + Self { + output: forge_domain::CommandOutput { + command: String::new(), + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + }, + } + } + } + + #[async_trait::async_trait] + impl HookCommandService for StubInfra { + async fn execute_command_with_input( + &self, + command: String, + _working_dir: PathBuf, + _stdin_input: String, + _env_vars: HashMap, + ) -> anyhow::Result { + let mut out = self.output.clone(); + out.command = command; + Ok(out) + } + } + + fn handler_for_event(infra: I, event_json: &str) -> UserHookHandler { + let config: UserHookConfig = serde_json::from_str(event_json).unwrap(); + UserHookHandler::new( + infra, + BTreeMap::new(), + config, + PathBuf::from("/tmp"), + "sess-test".to_string(), + ) + } + fn make_entry(command: &str) -> UserHookEntry { UserHookEntry { hook_type: UserHookType::Command, @@ -973,31 +982,8 @@ mod tests { let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); - // NullInfra returns exit_code=0 with empty stdout (Allow), so we need a custom - // infra that returns updatedInput JSON. - #[derive(Clone)] - struct UpdateInfra; - - #[async_trait::async_trait] - impl HookCommandService for UpdateInfra { - async fn execute_command_with_input( - &self, - command: String, - _working_dir: PathBuf, - _stdin_input: String, - _env_vars: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(0), - stdout: r#"{"updatedInput": {"command": "echo safe"}}"#.to_string(), - stderr: String::new(), - }) - } - } - let handler = UserHookHandler::new( - UpdateInfra, + StubInfra::new(Some(0), r#"{"updatedInput": {"command": "echo safe"}}"#, ""), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -1257,31 +1243,8 @@ mod tests { let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); - #[derive(Clone)] - struct UpdateInfra2; - - #[async_trait::async_trait] - impl HookCommandService for UpdateInfra2 { - async fn execute_command_with_input( - &self, - command: String, - _working_dir: PathBuf, - _stdin_input: String, - _env_vars: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(0), - stdout: - r#"{"updatedInput": {"file_path": "/safe/file.txt", "content": "hello"}}"# - .to_string(), - stderr: String::new(), - }) - } - } - let handler = UserHookHandler::new( - UpdateInfra2, + StubInfra::new(Some(0), r#"{"updatedInput": {"file_path": "/safe/file.txt", "content": "hello"}}"#, ""), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -1330,29 +1293,8 @@ mod tests { let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); - #[derive(Clone)] - struct BlockInfra; - - #[async_trait::async_trait] - impl HookCommandService for BlockInfra { - async fn execute_command_with_input( - &self, - command: String, - _working_dir: PathBuf, - _stdin_input: String, - _env_vars: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(2), - stdout: String::new(), - stderr: "dangerous operation".to_string(), - }) - } - } - let handler = UserHookHandler::new( - BlockInfra, + StubInfra::new(Some(2), "", "dangerous operation"), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -1457,21 +1399,6 @@ mod tests { // Tests: UserPromptSubmit blocking must return Err(PromptSuppressed) // ========================================================================= - /// Helper: creates a UserHookHandler with a given infra and - /// UserPromptSubmit config. - fn prompt_submit_handler(infra: I) -> UserHookHandler { - let json = - r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; - let config: UserHookConfig = serde_json::from_str(json).unwrap(); - UserHookHandler::new( - infra, - BTreeMap::new(), - config, - PathBuf::from("/tmp"), - "sess-test".to_string(), - ) - } - /// Helper: creates a RequestPayload EventData with the given request_count. fn request_event(request_count: usize) -> EventData { use forge_domain::{Agent, ModelId, ProviderId}; @@ -1501,28 +1428,7 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_block_exit2_returns_error() { // TC16: exit code 2 must return PromptSuppressed error. - #[derive(Clone)] - struct BlockExit2; - - #[async_trait::async_trait] - impl HookCommandService for BlockExit2 { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(2), - stdout: String::new(), - stderr: "policy violation".to_string(), - }) - } - } - - let handler = prompt_submit_handler(BlockExit2); + let handler = handler_for_event(StubInfra::new(Some(2), "", "policy violation"), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("hello"); @@ -1544,28 +1450,7 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_block_json_decision_returns_error() { // JSON {"decision":"block","reason":"Content policy"} must block. - #[derive(Clone)] - struct JsonBlockInfra; - - #[async_trait::async_trait] - impl HookCommandService for JsonBlockInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(0), - stdout: r#"{"decision":"block","reason":"Content policy"}"#.to_string(), - stderr: String::new(), - }) - } - } - - let handler = prompt_submit_handler(JsonBlockInfra); + let handler = handler_for_event(StubInfra::new(Some(0), r#"{"decision":"block","reason":"Content policy"}"#, ""), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("test"); @@ -1583,28 +1468,7 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_block_continue_false_returns_error() { // {"continue":false,"reason":"Blocked by admin"} must block. - #[derive(Clone)] - struct ContinueFalseInfra; - - #[async_trait::async_trait] - impl HookCommandService for ContinueFalseInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(0), - stdout: r#"{"continue":false,"reason":"Blocked by admin"}"#.to_string(), - stderr: String::new(), - }) - } - } - - let handler = prompt_submit_handler(ContinueFalseInfra); + let handler = handler_for_event(StubInfra::new(Some(0), r#"{"continue":false,"reason":"Blocked by admin"}"#, ""), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("test"); @@ -1622,7 +1486,7 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_allow_returns_ok() { // Exit 0 + empty stdout => allow, no feedback injected. - let handler = prompt_submit_handler(NullInfra); + let handler = handler_for_event(NullInfra, r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1637,28 +1501,7 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_non_blocking_error_returns_ok() { // Exit code 1 is a non-blocking error — must NOT block. - #[derive(Clone)] - struct Exit1Infra; - - #[async_trait::async_trait] - impl HookCommandService for Exit1Infra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(1), - stdout: String::new(), - stderr: "some error".to_string(), - }) - } - } - - let handler = prompt_submit_handler(Exit1Infra); + let handler = handler_for_event(StubInfra::new(Some(1), "", "some error"), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("hello"); @@ -1670,7 +1513,7 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_skipped_on_subsequent_requests() { // request_count > 0 means it's a retry, not a user prompt. - let handler = prompt_submit_handler(NullInfra); + let handler = handler_for_event(NullInfra, r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = request_event(1); // subsequent request let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1686,19 +1529,6 @@ mod tests { // Stop hook tests: Stop hooks fire and inject feedback for continuation // ========================================================================= - /// Helper: creates a UserHookHandler with Stop config and a given infra. - fn stop_handler(infra: I) -> UserHookHandler { - let json = r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; - let config: UserHookConfig = serde_json::from_str(json).unwrap(); - UserHookHandler::new( - infra, - BTreeMap::new(), - config, - PathBuf::from("/tmp"), - "sess-test".to_string(), - ) - } - /// Helper: creates an EndPayload EventData with optional stop_hook_active. fn end_event() -> EventData { use forge_domain::{Agent, ModelId, ProviderId}; @@ -1716,28 +1546,7 @@ mod tests { #[tokio::test] async fn test_stop_hook_exit_code_2_injects_message_and_sets_active() { - #[derive(Clone)] - struct StopBlockInfra; - - #[async_trait::async_trait] - impl HookCommandService for StopBlockInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(2), - stdout: String::new(), - stderr: "keep working".to_string(), - }) - } - } - - let handler = stop_handler(StopBlockInfra); + let handler = handler_for_event(StubInfra::new(Some(2), "", "keep working"), r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1766,7 +1575,7 @@ mod tests { #[tokio::test] async fn test_stop_hook_allow_returns_ok() { - let handler = stop_handler(NullInfra); + let handler = handler_for_event(NullInfra, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1781,28 +1590,7 @@ mod tests { #[tokio::test] async fn test_stop_hook_json_continue_false_injects_message() { - #[derive(Clone)] - struct StopJsonBlockInfra; - - #[async_trait::async_trait] - impl HookCommandService for StopJsonBlockInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(0), - stdout: r#"{"continue":false,"stopReason":"keep working"}"#.to_string(), - stderr: String::new(), - }) - } - } - - let handler = stop_handler(StopJsonBlockInfra); + let handler = handler_for_event(StubInfra::new(Some(0), r#"{"continue":false,"stopReason":"keep working"}"#, ""), r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1910,7 +1698,7 @@ mod tests { } let captured = Arc::new(Mutex::new(None)); - let handler = stop_handler(CapturingInfra { captured_input: captured.clone() }); + let handler = handler_for_event(CapturingInfra { captured_input: captured.clone() }, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); // Create event with stop_hook_active = true (simulating re-entrant call) let mut event = { use forge_domain::{Agent, ModelId, ProviderId}; @@ -1940,7 +1728,7 @@ mod tests { async fn test_stop_hook_allow_does_not_inject_message() { // When a Stop hook allows the stop (exit 0, no blocking JSON), no // message should be injected and stop_hook_active should remain false. - let handler = stop_handler(NullInfra); + let handler = handler_for_event(NullInfra, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1985,7 +1773,7 @@ mod tests { } let captured = Arc::new(Mutex::new(None)); - let handler = stop_handler(CapturingInfra2 { captured_input: captured.clone() }); + let handler = handler_for_event(CapturingInfra2 { captured_input: captured.clone() }, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = end_event(); // stop_hook_active defaults to false let mut conversation = conversation_with_user_msg("hello"); @@ -2002,20 +1790,6 @@ mod tests { // BUG-3 Tests: PostToolUse feedback must use wrapper // ========================================================================= - /// Helper: creates a UserHookHandler with PostToolUse config and given - /// infra. - fn post_tool_use_handler(infra: I) -> UserHookHandler { - let json = r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; - let config: UserHookConfig = serde_json::from_str(json).unwrap(); - UserHookHandler::new( - infra, - BTreeMap::new(), - config, - PathBuf::from("/tmp"), - "sess-test".to_string(), - ) - } - /// Helper: creates a ToolcallEndPayload EventData with a successful tool /// result. fn toolcall_end_event( @@ -2043,28 +1817,7 @@ mod tests { #[tokio::test] async fn test_post_tool_use_block_injects_important_feedback() { - #[derive(Clone)] - struct PostToolBlockInfra; - - #[async_trait::async_trait] - impl HookCommandService for PostToolBlockInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(2), - stdout: String::new(), - stderr: "sensitive data detected".to_string(), - }) - } - } - - let handler = post_tool_use_handler(PostToolBlockInfra); + let handler = handler_for_event(StubInfra::new(Some(2), "", "sensitive data detected"), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); @@ -2086,28 +1839,7 @@ mod tests { #[tokio::test] async fn test_post_tool_use_block_json_injects_feedback() { - #[derive(Clone)] - struct PostToolJsonBlockInfra; - - #[async_trait::async_trait] - impl HookCommandService for PostToolJsonBlockInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(0), - stdout: r#"{"decision":"block","reason":"PII detected"}"#.to_string(), - stderr: String::new(), - }) - } - } - - let handler = post_tool_use_handler(PostToolJsonBlockInfra); + let handler = handler_for_event(StubInfra::new(Some(0), r#"{"decision":"block","reason":"PII detected"}"#, ""), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); @@ -2126,7 +1858,7 @@ mod tests { #[tokio::test] async fn test_post_tool_use_allow_no_feedback() { - let handler = post_tool_use_handler(NullInfra); + let handler = handler_for_event(NullInfra, r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -2140,28 +1872,7 @@ mod tests { #[tokio::test] async fn test_post_tool_use_non_blocking_error_no_feedback() { - #[derive(Clone)] - struct Exit1PostInfra; - - #[async_trait::async_trait] - impl HookCommandService for Exit1PostInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(1), - stdout: String::new(), - stderr: "non-blocking error".to_string(), - }) - } - } - - let handler = post_tool_use_handler(Exit1PostInfra); + let handler = handler_for_event(StubInfra::new(Some(1), "", "non-blocking error"), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -2177,37 +1888,7 @@ mod tests { async fn test_post_tool_use_failure_event_fires_separately() { // PostToolUseFailure is a separate event from PostToolUse. // Configure only PostToolUseFailure hooks and fire with is_error=true. - #[derive(Clone)] - struct FailureBlockInfra; - - #[async_trait::async_trait] - impl HookCommandService for FailureBlockInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(2), - stdout: String::new(), - stderr: "error flagged".to_string(), - }) - } - } - - let json = - r#"{"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; - let config: UserHookConfig = serde_json::from_str(json).unwrap(); - let handler = UserHookHandler::new( - FailureBlockInfra, - BTreeMap::new(), - config, - PathBuf::from("/tmp"), - "sess-test".to_string(), - ); + let handler = handler_for_event(StubInfra::new(Some(2), "", "error flagged"), r#"{"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", true); let mut conversation = conversation_with_user_msg("hello"); @@ -2221,28 +1902,7 @@ mod tests { #[tokio::test] async fn test_post_tool_use_feedback_contains_tool_name() { - #[derive(Clone)] - struct PostToolBlockInfra2; - - #[async_trait::async_trait] - impl HookCommandService for PostToolBlockInfra2 { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(2), - stdout: String::new(), - stderr: "flagged".to_string(), - }) - } - } - - let handler = post_tool_use_handler(PostToolBlockInfra2); + let handler = handler_for_event(StubInfra::new(Some(2), "", "flagged"), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); @@ -2257,35 +1917,12 @@ mod tests { // Tests: additionalContext injection // ========================================================================= - /// Infra that returns exit 0 with `additionalContext` in JSON output. - #[derive(Clone)] - struct AdditionalContextInfra; - - #[async_trait::async_trait] - impl HookCommandService for AdditionalContextInfra { - async fn execute_command_with_input( - &self, - command: String, - _: PathBuf, - _: String, - _: HashMap, - ) -> anyhow::Result { - Ok(forge_domain::CommandOutput { - command, - exit_code: Some(0), - stdout: r#"{"additionalContext": "Remember to follow coding standards"}"# - .to_string(), - stderr: String::new(), - }) - } - } - #[tokio::test] async fn test_session_start_injects_additional_context() { let json = r#"{"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); let handler = UserHookHandler::new( - AdditionalContextInfra, + StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -2325,7 +1962,7 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_injects_additional_context() { let handler = UserHookHandler::new( - AdditionalContextInfra, + StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), BTreeMap::new(), serde_json::from_str( r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, @@ -2361,7 +1998,7 @@ mod tests { let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); let handler = UserHookHandler::new( - AdditionalContextInfra, + StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -2403,7 +2040,7 @@ mod tests { #[tokio::test] async fn test_post_tool_use_injects_additional_context() { - let handler = post_tool_use_handler(AdditionalContextInfra); + let handler = handler_for_event(StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_count = conversation.context.as_ref().unwrap().messages.len(); @@ -2428,7 +2065,7 @@ mod tests { #[tokio::test] async fn test_no_additional_context_when_empty() { // NullInfra returns empty stdout => no additionalContext - let handler = post_tool_use_handler(NullInfra); + let handler = handler_for_event(NullInfra, r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_count = conversation.context.as_ref().unwrap().messages.len(); diff --git a/crates/forge_config/src/hooks.rs b/crates/forge_config/src/hooks.rs index d1d7a4d4e2..a834192ec4 100644 --- a/crates/forge_config/src/hooks.rs +++ b/crates/forge_config/src/hooks.rs @@ -42,6 +42,7 @@ impl UserHookConfig { pub fn is_empty(&self) -> bool { self.events.is_empty() } + } /// Supported hook event names that map to lifecycle points in the @@ -56,29 +57,12 @@ pub enum UserHookEventName { PostToolUseFailure, /// Fired when the agent finishes responding. Can block stop to continue. Stop, - /// FIXME: Fired when a notification is sent; no lifecycle point fires this - /// event and no handler exists yet. - Notification, /// Fired when a session starts or resumes. SessionStart, /// Fired when a session ends/terminates. SessionEnd, /// Fired when a user prompt is submitted. UserPromptSubmit, - /// FIXME: Fired before context compaction; no lifecycle point fires this - /// event and no handler exists yet. - PreCompact, - /// FIXME: Fired after context compaction; no lifecycle point fires this - /// event and no handler exists yet. - PostCompact, - /// FIXME: Fired when a subagent starts; no lifecycle point fires this - SubagentStart, - /// FIXME: Fired when a subagent stops; no lifecycle point fires this - SubagentStop, - /// FIXME: no lifecycle point fires this - PermissionRequest, - /// FIXME: no lifecycle point fires this - Setup, } /// A matcher group pairs an optional regex matcher with a list of hook diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index 49d5fd1e23..b0815a8799 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -387,7 +387,6 @@ impl From<&ForgeConfig> for Info { .add_key_value("ForgeCode Service URL", config.services_url.to_string()) .add_title("TOOL CONFIGURATION") .add_key_value("Tool Timeout", format!("{}s", config.tool_timeout_secs)) - .add_key("Hook Timed out") .add_key_value( "Max Image Size", format!("{} bytes", config.max_image_size_bytes), From e46d2d660995010b1370f972faee407f25a78aee Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:45:19 -0400 Subject: [PATCH 58/64] test(hooks): use default end payload in title generation test --- crates/forge_app/src/hooks/title_generation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_app/src/hooks/title_generation.rs b/crates/forge_app/src/hooks/title_generation.rs index ecea4b5033..8f9f03e026 100644 --- a/crates/forge_app/src/hooks/title_generation.rs +++ b/crates/forge_app/src/hooks/title_generation.rs @@ -262,7 +262,7 @@ mod tests { .insert(conversation.id, TitleGenerationState { rx, handle }); handler - .handle(&event(EndPayload), &mut conversation) + .handle(&mut event(EndPayload::default()), &mut conversation) .await .unwrap(); From 192c969513c53ca9731ef00c83e590e30180f24f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 03:48:59 +0000 Subject: [PATCH 59/64] [autofix.ci] apply automated fixes --- .../forge_app/src/hooks/user_hook_handler.rs | 246 +++++++++++++++--- crates/forge_config/src/hooks.rs | 1 - 2 files changed, 203 insertions(+), 44 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 3f3423ab1a..9f6634f00a 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -74,7 +74,11 @@ impl UserHookHandler { /// Constructs a [`HookInput`] from the common fields stored in this /// handler, leaving only the event-specific `event_data` to the caller. - fn build_base_input(&self, event_name: &UserHookEventName, event_data: HookEventInput) -> HookInput { + fn build_base_input( + &self, + event_name: &UserHookEventName, + event_data: HookEventInput, + ) -> HookInput { HookInput { hook_event_name: event_name.to_string(), cwd: self.cwd.to_string_lossy().to_string(), @@ -256,7 +260,9 @@ impl UserHookHandler { /// Processes hook results, returning the blocking command and reason if /// any hook blocked. fn process_results(results: &[(String, HookExecutionResult)]) -> Option<(String, String)> { - results.iter().find_map(|(cmd, result)| Self::check_blocking(cmd, result)) + results + .iter() + .find_map(|(cmd, result)| Self::check_blocking(cmd, result)) } /// Collects `additionalContext` strings from all successful hook results, @@ -369,9 +375,19 @@ impl EventHandle> for UserHookHan return Ok(()); } - let input = self.build_base_input(&UserHookEventName::SessionStart, HookEventInput::SessionStart { source: "startup".to_string() }); + let input = self.build_base_input( + &UserHookEventName::SessionStart, + HookEventInput::SessionStart { source: "startup".to_string() }, + ); - self.run_hooks_and_collect(&UserHookEventName::SessionStart, Some("startup"), &input, &mut event.warnings, conversation).await; + self.run_hooks_and_collect( + &UserHookEventName::SessionStart, + Some("startup"), + &input, + &mut event.warnings, + conversation, + ) + .await; Ok(()) } @@ -409,9 +425,20 @@ impl EventHandle> for UserHookH }) .unwrap_or_default(); - let input = self.build_base_input(&UserHookEventName::UserPromptSubmit, HookEventInput::UserPromptSubmit { prompt }); + let input = self.build_base_input( + &UserHookEventName::UserPromptSubmit, + HookEventInput::UserPromptSubmit { prompt }, + ); - let results = self.run_hooks_and_collect(&UserHookEventName::UserPromptSubmit, None, &input, &mut event.warnings, conversation).await; + let results = self + .run_hooks_and_collect( + &UserHookEventName::UserPromptSubmit, + None, + &input, + &mut event.warnings, + conversation, + ) + .await; if let Some((command, reason)) = Self::process_results(&results) { debug!( @@ -468,13 +495,20 @@ impl EventHandle> for Use .as_ref() .map(|id| id.as_str().to_string()); - let input = self.build_base_input(&UserHookEventName::PreToolUse, HookEventInput::PreToolUse { - tool_name: tool_name.clone(), - tool_input, - tool_use_id, - }); + let input = self.build_base_input( + &UserHookEventName::PreToolUse, + HookEventInput::PreToolUse { tool_name: tool_name.clone(), tool_input, tool_use_id }, + ); - let results = self.run_hooks_and_collect(&UserHookEventName::PreToolUse, Some(tool_name.as_str()), &input, &mut event.warnings, conversation).await; + let results = self + .run_hooks_and_collect( + &UserHookEventName::PreToolUse, + Some(tool_name.as_str()), + &input, + &mut event.warnings, + conversation, + ) + .await; let decision = Self::process_pre_tool_use_output(&results); @@ -540,14 +574,25 @@ impl EventHandle> for UserH .as_ref() .map(|id| id.as_str().to_string()); - let input = self.build_base_input(&event_name, HookEventInput::PostToolUse { + let input = self.build_base_input( + &event_name, + HookEventInput::PostToolUse { tool_name: tool_name.to_string(), tool_input, tool_response, tool_use_id, - }); + }, + ); - let results = self.run_hooks_and_collect(&event_name, Some(&tool_name), &input, &mut event.warnings, conversation).await; + let results = self + .run_hooks_and_collect( + &event_name, + Some(&tool_name), + &input, + &mut event.warnings, + conversation, + ) + .await; // PostToolUse blocking: store the feedback on the event payload. // The orchestrator reads `hook_feedback` after `append_message` and @@ -582,8 +627,16 @@ impl EventHandle> for UserHookHandl ) -> anyhow::Result<()> { // Fire SessionEnd hooks if self.has_hooks(&UserHookEventName::SessionEnd) { - let input = self.build_base_input(&UserHookEventName::SessionEnd, HookEventInput::Empty {}); - self.run_hooks_and_collect(&UserHookEventName::SessionEnd, None, &input, &mut event.warnings, conversation).await; + let input = + self.build_base_input(&UserHookEventName::SessionEnd, HookEventInput::Empty {}); + self.run_hooks_and_collect( + &UserHookEventName::SessionEnd, + None, + &input, + &mut event.warnings, + conversation, + ) + .await; } // Fire Stop hooks @@ -603,9 +656,20 @@ impl EventHandle> for UserHookHandl .map(|s| s.to_string()) }); - let input = self.build_base_input(&UserHookEventName::Stop, HookEventInput::Stop { stop_hook_active, last_assistant_message }); + let input = self.build_base_input( + &UserHookEventName::Stop, + HookEventInput::Stop { stop_hook_active, last_assistant_message }, + ); - let results = self.run_hooks_and_collect(&UserHookEventName::Stop, None, &input, &mut event.warnings, conversation).await; + let results = self + .run_hooks_and_collect( + &UserHookEventName::Stop, + None, + &input, + &mut event.warnings, + conversation, + ) + .await; if let Some((command, reason)) = Self::process_results(&results) { debug!( @@ -1244,7 +1308,11 @@ mod tests { let config: UserHookConfig = serde_json::from_str(json).unwrap(); let handler = UserHookHandler::new( - StubInfra::new(Some(0), r#"{"updatedInput": {"file_path": "/safe/file.txt", "content": "hello"}}"#, ""), + StubInfra::new( + Some(0), + r#"{"updatedInput": {"file_path": "/safe/file.txt", "content": "hello"}}"#, + "", + ), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -1428,7 +1496,10 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_block_exit2_returns_error() { // TC16: exit code 2 must return PromptSuppressed error. - let handler = handler_for_event(StubInfra::new(Some(2), "", "policy violation"), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new(Some(2), "", "policy violation"), + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("hello"); @@ -1450,7 +1521,14 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_block_json_decision_returns_error() { // JSON {"decision":"block","reason":"Content policy"} must block. - let handler = handler_for_event(StubInfra::new(Some(0), r#"{"decision":"block","reason":"Content policy"}"#, ""), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new( + Some(0), + r#"{"decision":"block","reason":"Content policy"}"#, + "", + ), + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("test"); @@ -1468,7 +1546,14 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_block_continue_false_returns_error() { // {"continue":false,"reason":"Blocked by admin"} must block. - let handler = handler_for_event(StubInfra::new(Some(0), r#"{"continue":false,"reason":"Blocked by admin"}"#, ""), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new( + Some(0), + r#"{"continue":false,"reason":"Blocked by admin"}"#, + "", + ), + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("test"); @@ -1486,7 +1571,10 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_allow_returns_ok() { // Exit 0 + empty stdout => allow, no feedback injected. - let handler = handler_for_event(NullInfra, r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + NullInfra, + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1501,7 +1589,10 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_non_blocking_error_returns_ok() { // Exit code 1 is a non-blocking error — must NOT block. - let handler = handler_for_event(StubInfra::new(Some(1), "", "some error"), r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new(Some(1), "", "some error"), + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = request_event(0); let mut conversation = conversation_with_user_msg("hello"); @@ -1513,7 +1604,10 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_skipped_on_subsequent_requests() { // request_count > 0 means it's a retry, not a user prompt. - let handler = handler_for_event(NullInfra, r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + NullInfra, + r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = request_event(1); // subsequent request let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1546,7 +1640,10 @@ mod tests { #[tokio::test] async fn test_stop_hook_exit_code_2_injects_message_and_sets_active() { - let handler = handler_for_event(StubInfra::new(Some(2), "", "keep working"), r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new(Some(2), "", "keep working"), + r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1575,7 +1672,10 @@ mod tests { #[tokio::test] async fn test_stop_hook_allow_returns_ok() { - let handler = handler_for_event(NullInfra, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + NullInfra, + r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1590,7 +1690,14 @@ mod tests { #[tokio::test] async fn test_stop_hook_json_continue_false_injects_message() { - let handler = handler_for_event(StubInfra::new(Some(0), r#"{"continue":false,"stopReason":"keep working"}"#, ""), r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new( + Some(0), + r#"{"continue":false,"stopReason":"keep working"}"#, + "", + ), + r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1698,7 +1805,10 @@ mod tests { } let captured = Arc::new(Mutex::new(None)); - let handler = handler_for_event(CapturingInfra { captured_input: captured.clone() }, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + CapturingInfra { captured_input: captured.clone() }, + r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); // Create event with stop_hook_active = true (simulating re-entrant call) let mut event = { use forge_domain::{Agent, ModelId, ProviderId}; @@ -1728,7 +1838,10 @@ mod tests { async fn test_stop_hook_allow_does_not_inject_message() { // When a Stop hook allows the stop (exit 0, no blocking JSON), no // message should be injected and stop_hook_active should remain false. - let handler = handler_for_event(NullInfra, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + NullInfra, + r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = end_event(); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1773,7 +1886,10 @@ mod tests { } let captured = Arc::new(Mutex::new(None)); - let handler = handler_for_event(CapturingInfra2 { captured_input: captured.clone() }, r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + CapturingInfra2 { captured_input: captured.clone() }, + r#"{"Stop": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = end_event(); // stop_hook_active defaults to false let mut conversation = conversation_with_user_msg("hello"); @@ -1817,7 +1933,10 @@ mod tests { #[tokio::test] async fn test_post_tool_use_block_injects_important_feedback() { - let handler = handler_for_event(StubInfra::new(Some(2), "", "sensitive data detected"), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new(Some(2), "", "sensitive data detected"), + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); @@ -1839,7 +1958,14 @@ mod tests { #[tokio::test] async fn test_post_tool_use_block_json_injects_feedback() { - let handler = handler_for_event(StubInfra::new(Some(0), r#"{"decision":"block","reason":"PII detected"}"#, ""), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new( + Some(0), + r#"{"decision":"block","reason":"PII detected"}"#, + "", + ), + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); @@ -1858,7 +1984,10 @@ mod tests { #[tokio::test] async fn test_post_tool_use_allow_no_feedback() { - let handler = handler_for_event(NullInfra, r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + NullInfra, + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1872,7 +2001,10 @@ mod tests { #[tokio::test] async fn test_post_tool_use_non_blocking_error_no_feedback() { - let handler = handler_for_event(StubInfra::new(Some(1), "", "non-blocking error"), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new(Some(1), "", "non-blocking error"), + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_msg_count = conversation.context.as_ref().unwrap().messages.len(); @@ -1888,7 +2020,10 @@ mod tests { async fn test_post_tool_use_failure_event_fires_separately() { // PostToolUseFailure is a separate event from PostToolUse. // Configure only PostToolUseFailure hooks and fire with is_error=true. - let handler = handler_for_event(StubInfra::new(Some(2), "", "error flagged"), r#"{"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new(Some(2), "", "error flagged"), + r#"{"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", true); let mut conversation = conversation_with_user_msg("hello"); @@ -1902,7 +2037,10 @@ mod tests { #[tokio::test] async fn test_post_tool_use_feedback_contains_tool_name() { - let handler = handler_for_event(StubInfra::new(Some(2), "", "flagged"), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new(Some(2), "", "flagged"), + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); @@ -1922,7 +2060,11 @@ mod tests { let json = r#"{"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); let handler = UserHookHandler::new( - StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), + StubInfra::new( + Some(0), + r#"{"additionalContext": "Remember to follow coding standards"}"#, + "", + ), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -1962,7 +2104,11 @@ mod tests { #[tokio::test] async fn test_user_prompt_submit_injects_additional_context() { let handler = UserHookHandler::new( - StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), + StubInfra::new( + Some(0), + r#"{"additionalContext": "Remember to follow coding standards"}"#, + "", + ), BTreeMap::new(), serde_json::from_str( r#"{"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, @@ -1998,7 +2144,11 @@ mod tests { let json = r#"{"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#; let config: UserHookConfig = serde_json::from_str(json).unwrap(); let handler = UserHookHandler::new( - StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), + StubInfra::new( + Some(0), + r#"{"additionalContext": "Remember to follow coding standards"}"#, + "", + ), BTreeMap::new(), config, PathBuf::from("/tmp"), @@ -2040,7 +2190,14 @@ mod tests { #[tokio::test] async fn test_post_tool_use_injects_additional_context() { - let handler = handler_for_event(StubInfra::new(Some(0), r#"{"additionalContext": "Remember to follow coding standards"}"#, ""), r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + StubInfra::new( + Some(0), + r#"{"additionalContext": "Remember to follow coding standards"}"#, + "", + ), + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_count = conversation.context.as_ref().unwrap().messages.len(); @@ -2065,7 +2222,10 @@ mod tests { #[tokio::test] async fn test_no_additional_context_when_empty() { // NullInfra returns empty stdout => no additionalContext - let handler = handler_for_event(NullInfra, r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#); + let handler = handler_for_event( + NullInfra, + r#"{"PostToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#, + ); let mut event = toolcall_end_event("shell", false); let mut conversation = conversation_with_user_msg("hello"); let original_count = conversation.context.as_ref().unwrap().messages.len(); diff --git a/crates/forge_config/src/hooks.rs b/crates/forge_config/src/hooks.rs index a834192ec4..851a6efe61 100644 --- a/crates/forge_config/src/hooks.rs +++ b/crates/forge_config/src/hooks.rs @@ -42,7 +42,6 @@ impl UserHookConfig { pub fn is_empty(&self) -> bool { self.events.is_empty() } - } /// Supported hook event names that map to lifecycle points in the From 543ae89f4580d3cfc49a25b78f6bde486abdeeb0 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:47:42 -0400 Subject: [PATCH 60/64] test(config): add hook config fixtures for pipeline and layered defaults --- .../src/fixtures/hook_config_pipeline.toml | 6 ++++++ .../src/fixtures/hook_layered_with_defaults.toml | 12 ++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 crates/forge_config/src/fixtures/hook_config_pipeline.toml create mode 100644 crates/forge_config/src/fixtures/hook_layered_with_defaults.toml diff --git a/crates/forge_config/src/fixtures/hook_config_pipeline.toml b/crates/forge_config/src/fixtures/hook_config_pipeline.toml new file mode 100644 index 0000000000..aff8502b3c --- /dev/null +++ b/crates/forge_config/src/fixtures/hook_config_pipeline.toml @@ -0,0 +1,6 @@ +[[hooks.PreToolUse]] +matcher = "Bash" + + [[hooks.PreToolUse.hooks]] + type = "command" + command = "echo 'blocked'" diff --git a/crates/forge_config/src/fixtures/hook_layered_with_defaults.toml b/crates/forge_config/src/fixtures/hook_layered_with_defaults.toml new file mode 100644 index 0000000000..c2408cd916 --- /dev/null +++ b/crates/forge_config/src/fixtures/hook_layered_with_defaults.toml @@ -0,0 +1,12 @@ +[[hooks.PreToolUse]] +matcher = "Bash" + + [[hooks.PreToolUse.hooks]] + type = "command" + command = "echo 'pre'" + +[[hooks.Stop]] + + [[hooks.Stop.hooks]] + type = "command" + command = "stop.sh" From 030183901627ac3a04ba554d291ed7f0ee34bba4 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:47:47 -0400 Subject: [PATCH 61/64] test(config): add tests for hook parsing through config pipeline --- crates/forge_config/src/config.rs | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 69084e2772..17429b2c5e 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -394,4 +394,47 @@ mod tests { let actual: ForgeConfig = toml_edit::de::from_str(toml).unwrap(); assert_eq!(actual.hooks, None); } + + /// Verifies hooks survive the `config` crate pipeline (which lowercases + /// TOML keys internally). This is the path used by `read_global()`. + #[test] + fn test_hooks_through_config_crate_pipeline() { + use crate::UserHookEventName; + + let toml = include_str!("fixtures/hook_config_pipeline.toml"); + let result = ConfigReader::default().read_toml(toml).build(); + let actual = result.expect("hooks should parse through config crate pipeline"); + + let hooks = actual.hooks.expect("hooks should be Some"); + let groups = hooks.get_groups(&UserHookEventName::PreToolUse); + assert_eq!(groups.len(), 1, "expected 1 PreToolUse matcher group"); + assert_eq!(groups[0].matcher, Some("Bash".to_string())); + assert_eq!(groups[0].hooks.len(), 1); + assert_eq!( + groups[0].hooks[0].command, + Some("echo 'blocked'".to_string()) + ); + } + + /// Verifies hooks survive when layered with defaults via `ConfigReader`. + #[test] + fn test_hooks_layered_with_defaults() { + use crate::UserHookEventName; + + let hooks_toml = include_str!("fixtures/hook_layered_with_defaults.toml"); + let actual = ConfigReader::default() + .read_defaults() + .read_toml(hooks_toml) + .build() + .expect("hooks should parse when layered with defaults"); + + let hooks = actual.hooks.expect("hooks should be Some"); + assert_eq!(hooks.get_groups(&UserHookEventName::PreToolUse).len(), 1); + assert_eq!(hooks.get_groups(&UserHookEventName::Stop).len(), 1); + assert!( + hooks + .get_groups(&UserHookEventName::SessionStart) + .is_empty() + ); + } } From bdb9d6c18a7fe74a8a3afffff950c5ab5655b132 Mon Sep 17 00:00:00 2001 From: Sandipsinh Dilipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Sat, 11 Apr 2026 02:03:28 -0400 Subject: [PATCH 62/64] perf(hooks): cache pre-compiled regex patterns in UserHookHandler --- .../forge_app/src/hooks/user_hook_handler.rs | 108 ++++++++++++++---- 1 file changed, 85 insertions(+), 23 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index 9f6634f00a..f30fed733b 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -31,6 +31,9 @@ pub struct UserHookHandler { config: UserHookConfig, cwd: PathBuf, env_vars: HashMap, + /// Pre-compiled regex cache keyed by the raw pattern string. + /// Built once during construction from the immutable config patterns. + regex_cache: HashMap, } impl UserHookHandler { @@ -59,12 +62,46 @@ impl UserHookHandler { env_vars.insert("FORGE_SESSION_ID".to_string(), session_id); env_vars.insert("FORGE_CWD".to_string(), cwd.to_string_lossy().to_string()); + // Pre-compile all regex patterns from the config into a cache. + let regex_cache = Self::build_regex_cache(&config); + Self { executor: UserHookExecutor::new(service), config, cwd, env_vars: env_vars.into_iter().collect(), + regex_cache, + } + } + + /// Pre-compiles all unique, non-empty regex patterns found in the config. + /// + /// Invalid patterns are logged and skipped so that construction never + /// fails. The same warning will fire at match time for any pattern + /// missing from the cache. + fn build_regex_cache(config: &UserHookConfig) -> HashMap { + let mut cache = HashMap::new(); + for groups in config.events.values() { + for group in groups { + if let Some(pattern) = &group.matcher { + if !pattern.is_empty() && !cache.contains_key(pattern) { + match Regex::new(pattern) { + Ok(re) => { + cache.insert(pattern.clone(), re); + } + Err(e) => { + warn!( + pattern = pattern, + error = %e, + "Invalid regex in hook matcher, will be skipped at match time" + ); + } + } + } + } + } } + cache } /// Checks if the config has any hooks for the given event. @@ -89,30 +126,24 @@ impl UserHookHandler { /// Finds matching hook entries for an event, filtered by the optional /// matcher regex against the given subject string. + /// + /// Uses the pre-compiled `regex_cache` to avoid recompiling patterns on + /// every invocation. Patterns that failed compilation during construction + /// are silently skipped (already warned at startup). fn find_matching_hooks<'a>( groups: &'a [UserHookMatcherGroup], subject: Option<&str>, + regex_cache: &HashMap, ) -> Vec<&'a UserHookEntry> { let mut matching = Vec::new(); for group in groups { let matches = match (&group.matcher, subject) { - (Some(pattern), Some(subj)) => match Regex::new(pattern) { - Ok(re) => re.is_match(subj), - Err(e) => { - warn!( - pattern = pattern, - error = %e, - "Invalid regex in hook matcher, skipping" - ); - false - } - }, (None, _) => { // No matcher means unconditional match true } - (Some(x), _) if x.is_empty() => { + (Some(pattern), _) if pattern.is_empty() => { // Empty matcher is treated as unconditional (same as None) true } @@ -120,6 +151,11 @@ impl UserHookHandler { // Matcher specified but no subject to match against; skip false } + (Some(pattern), Some(subj)) => { + regex_cache + .get(pattern) + .map_or(false, |re| re.is_match(subj)) + } }; if matches { @@ -221,7 +257,7 @@ impl UserHookHandler { I: HookCommandService, { let groups = self.config.get_groups(event_name); - let hooks = Self::find_matching_hooks(groups, subject); + let hooks = Self::find_matching_hooks(groups, subject, &self.regex_cache); if hooks.is_empty() { return Vec::new(); @@ -801,10 +837,27 @@ mod tests { } } + /// Builds a regex cache from a slice of matcher groups, mirroring the + /// logic in `UserHookHandler::build_regex_cache` for test use. + fn regex_cache_from_groups(groups: &[UserHookMatcherGroup]) -> HashMap { + let mut cache = HashMap::new(); + for group in groups { + if let Some(pattern) = &group.matcher { + if !pattern.is_empty() && !cache.contains_key(pattern) { + if let Ok(re) = Regex::new(pattern) { + cache.insert(pattern.clone(), re); + } + } + } + } + cache + } + #[test] fn test_find_matching_hooks_no_matcher_fires_unconditionally() { let groups = vec![make_group(None, &["echo hi"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); assert_eq!(actual[0].command, Some("echo hi".to_string())); } @@ -812,42 +865,48 @@ mod tests { #[test] fn test_find_matching_hooks_no_matcher_fires_without_subject() { let groups = vec![make_group(None, &["echo hi"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, None); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, None, &cache); assert_eq!(actual.len(), 1); } #[test] fn test_find_matching_hooks_regex_match() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); } #[test] fn test_find_matching_hooks_regex_no_match() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Write")); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Write"), &cache); assert!(actual.is_empty()); } #[test] fn test_find_matching_hooks_regex_partial_match() { let groups = vec![make_group(Some("Bash|Write"), &["check.sh"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); } #[test] fn test_find_matching_hooks_matcher_but_no_subject() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, None); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, None, &cache); assert!(actual.is_empty()); } #[test] fn test_find_matching_hooks_empty_matcher_fires_without_subject() { let groups = vec![make_group(Some(""), &["stop-hook.sh"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, None); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, None, &cache); assert_eq!(actual.len(), 1); assert_eq!(actual[0].command, Some("stop-hook.sh".to_string())); } @@ -855,7 +914,8 @@ mod tests { #[test] fn test_find_matching_hooks_empty_matcher_fires_with_subject() { let groups = vec![make_group(Some(""), &["pre-tool.sh"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); assert_eq!(actual[0].command, Some("pre-tool.sh".to_string())); } @@ -863,7 +923,8 @@ mod tests { #[test] fn test_find_matching_hooks_invalid_regex_skipped() { let groups = vec![make_group(Some("[invalid"), &["block.sh"])]; - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("anything")); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("anything"), &cache); assert!(actual.is_empty()); } @@ -874,7 +935,8 @@ mod tests { make_group(Some("Write"), &["write-hook.sh"]), make_group(None, &["always.sh"]), ]; - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash")); + let cache = regex_cache_from_groups(&groups); + let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 2); // Bash match + unconditional } From 75fbb6eb2d6ed1914afcfb9ed303ada0b016412c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:05:36 +0000 Subject: [PATCH 63/64] [autofix.ci] apply automated fixes --- .../forge_app/src/hooks/user_hook_handler.rs | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index f30fed733b..c31525e61c 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -83,8 +83,8 @@ impl UserHookHandler { let mut cache = HashMap::new(); for groups in config.events.values() { for group in groups { - if let Some(pattern) = &group.matcher { - if !pattern.is_empty() && !cache.contains_key(pattern) { + if let Some(pattern) = &group.matcher + && !pattern.is_empty() && !cache.contains_key(pattern) { match Regex::new(pattern) { Ok(re) => { cache.insert(pattern.clone(), re); @@ -98,7 +98,6 @@ impl UserHookHandler { } } } - } } } cache @@ -151,11 +150,9 @@ impl UserHookHandler { // Matcher specified but no subject to match against; skip false } - (Some(pattern), Some(subj)) => { - regex_cache - .get(pattern) - .map_or(false, |re| re.is_match(subj)) - } + (Some(pattern), Some(subj)) => regex_cache + .get(pattern) + .is_some_and(|re| re.is_match(subj)), }; if matches { @@ -842,13 +839,11 @@ mod tests { fn regex_cache_from_groups(groups: &[UserHookMatcherGroup]) -> HashMap { let mut cache = HashMap::new(); for group in groups { - if let Some(pattern) = &group.matcher { - if !pattern.is_empty() && !cache.contains_key(pattern) { - if let Ok(re) = Regex::new(pattern) { + if let Some(pattern) = &group.matcher + && !pattern.is_empty() && !cache.contains_key(pattern) + && let Ok(re) = Regex::new(pattern) { cache.insert(pattern.clone(), re); } - } - } } cache } @@ -857,7 +852,8 @@ mod tests { fn test_find_matching_hooks_no_matcher_fires_unconditionally() { let groups = vec![make_group(None, &["echo hi"])]; let cache = regex_cache_from_groups(&groups); - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); + let actual = + UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); assert_eq!(actual[0].command, Some("echo hi".to_string())); } @@ -874,7 +870,8 @@ mod tests { fn test_find_matching_hooks_regex_match() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; let cache = regex_cache_from_groups(&groups); - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); + let actual = + UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); } @@ -882,7 +879,8 @@ mod tests { fn test_find_matching_hooks_regex_no_match() { let groups = vec![make_group(Some("Bash"), &["block.sh"])]; let cache = regex_cache_from_groups(&groups); - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Write"), &cache); + let actual = + UserHookHandler::::find_matching_hooks(&groups, Some("Write"), &cache); assert!(actual.is_empty()); } @@ -890,7 +888,8 @@ mod tests { fn test_find_matching_hooks_regex_partial_match() { let groups = vec![make_group(Some("Bash|Write"), &["check.sh"])]; let cache = regex_cache_from_groups(&groups); - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); + let actual = + UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); } @@ -915,7 +914,8 @@ mod tests { fn test_find_matching_hooks_empty_matcher_fires_with_subject() { let groups = vec![make_group(Some(""), &["pre-tool.sh"])]; let cache = regex_cache_from_groups(&groups); - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); + let actual = + UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 1); assert_eq!(actual[0].command, Some("pre-tool.sh".to_string())); } @@ -924,7 +924,8 @@ mod tests { fn test_find_matching_hooks_invalid_regex_skipped() { let groups = vec![make_group(Some("[invalid"), &["block.sh"])]; let cache = regex_cache_from_groups(&groups); - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("anything"), &cache); + let actual = + UserHookHandler::::find_matching_hooks(&groups, Some("anything"), &cache); assert!(actual.is_empty()); } @@ -936,7 +937,8 @@ mod tests { make_group(None, &["always.sh"]), ]; let cache = regex_cache_from_groups(&groups); - let actual = UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); + let actual = + UserHookHandler::::find_matching_hooks(&groups, Some("Bash"), &cache); assert_eq!(actual.len(), 2); // Bash match + unconditional } From c3bda18a0ed7d828b4d69f15fac5b082a51ff9ba Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:07:23 +0000 Subject: [PATCH 64/64] [autofix.ci] apply automated fixes (attempt 2/3) --- .../forge_app/src/hooks/user_hook_handler.rs | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/crates/forge_app/src/hooks/user_hook_handler.rs b/crates/forge_app/src/hooks/user_hook_handler.rs index c31525e61c..46325313cd 100644 --- a/crates/forge_app/src/hooks/user_hook_handler.rs +++ b/crates/forge_app/src/hooks/user_hook_handler.rs @@ -84,20 +84,22 @@ impl UserHookHandler { for groups in config.events.values() { for group in groups { if let Some(pattern) = &group.matcher - && !pattern.is_empty() && !cache.contains_key(pattern) { - match Regex::new(pattern) { - Ok(re) => { - cache.insert(pattern.clone(), re); - } - Err(e) => { - warn!( - pattern = pattern, - error = %e, - "Invalid regex in hook matcher, will be skipped at match time" - ); - } + && !pattern.is_empty() + && !cache.contains_key(pattern) + { + match Regex::new(pattern) { + Ok(re) => { + cache.insert(pattern.clone(), re); + } + Err(e) => { + warn!( + pattern = pattern, + error = %e, + "Invalid regex in hook matcher, will be skipped at match time" + ); } } + } } } cache @@ -150,9 +152,9 @@ impl UserHookHandler { // Matcher specified but no subject to match against; skip false } - (Some(pattern), Some(subj)) => regex_cache - .get(pattern) - .is_some_and(|re| re.is_match(subj)), + (Some(pattern), Some(subj)) => { + regex_cache.get(pattern).is_some_and(|re| re.is_match(subj)) + } }; if matches { @@ -840,10 +842,12 @@ mod tests { let mut cache = HashMap::new(); for group in groups { if let Some(pattern) = &group.matcher - && !pattern.is_empty() && !cache.contains_key(pattern) - && let Ok(re) = Regex::new(pattern) { - cache.insert(pattern.clone(), re); - } + && !pattern.is_empty() + && !cache.contains_key(pattern) + && let Ok(re) = Regex::new(pattern) + { + cache.insert(pattern.clone(), re); + } } cache }