diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index fccf516b145b..7f3a923c645c 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -110,4 +110,8 @@ impl ToolExecutor for CodeModeWaitHandler { } } -impl CoreToolRuntime for CodeModeWaitHandler {} +impl CoreToolRuntime for CodeModeWaitHandler { + fn supports_default_function_tool_hooks(&self) -> bool { + false + } +} diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 764e66f0cec1..32ec1b85b00e 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,20 +1,14 @@ use std::sync::Arc; -use codex_tools::ToolCall as ExtensionToolCall; -use codex_tools::ToolName; -use codex_tools::ToolSpec; -use serde_json::Value; - use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; -use crate::tools::flat_tool_name; -use crate::tools::hook_names::HookToolName; use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; +use codex_tools::ToolCall as ExtensionToolCall; +use codex_tools::ToolName; +use codex_tools::ToolSpec; pub(crate) struct ExtensionToolAdapter(Arc>); @@ -61,29 +55,6 @@ impl CoreToolRuntime for ExtensionToolAdapter { fn matches_kind(&self, payload: &ToolPayload) -> bool { self.arguments_from_payload(payload).is_some() } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - let arguments = self.arguments_from_payload(&invocation.payload)?; - Some(PreToolUsePayload { - tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()), - tool_input: extension_tool_hook_input(arguments), - }) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &dyn ToolOutput, - ) -> Option { - let arguments = self.arguments_from_payload(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()), - tool_use_id: invocation.call_id.clone(), - tool_input: extension_tool_hook_input(arguments), - tool_response: result - .post_tool_use_response(&invocation.call_id, &invocation.payload)?, - }) - } } fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { @@ -96,14 +67,6 @@ fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { } } -fn extension_tool_hook_input(arguments: &str) -> Value { - if arguments.trim().is_empty() { - return Value::Object(serde_json::Map::new()); - } - - serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string())) -} - #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index a6f3cf4ced32..cccc76020821 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -134,7 +134,6 @@ impl CoreToolRuntime for McpHandler { tags }) } - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { let ToolPayload::Function { arguments } = &invocation.payload else { return None; @@ -279,6 +278,9 @@ mod tests { use super::*; use crate::session::tests::make_session_and_context; use crate::tools::context::ToolCallSource; + use crate::tools::hook_names::HookToolName; + use crate::tools::registry::PostToolUsePayload; + use crate::tools::registry::PreToolUsePayload; use crate::turn_diff_tracker::TurnDiffTracker; use pretty_assertions::assert_eq; use serde_json::json; @@ -438,11 +440,6 @@ mod tests { ); } - #[test] - fn mcp_hook_tool_input_defaults_empty_args_to_object() { - assert_eq!(mcp_hook_tool_input(" "), json!({})); - } - fn tool_info(server_name: &str, callable_namespace: &str, tool_name: &str) -> ToolInfo { ToolInfo { server_name: server_name.to_string(), diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 70a31fe0e063..ef670f8f84d3 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -82,4 +82,8 @@ impl ToolExecutor for RequestPermissionsHandler { } } -impl CoreToolRuntime for RequestPermissionsHandler {} +impl CoreToolRuntime for RequestPermissionsHandler { + fn supports_default_function_tool_hooks(&self) -> bool { + false + } +} diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index dfa4240ebce6..2bd8f2b26820 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -101,6 +101,10 @@ impl CoreToolRuntime for WriteStdinHandler { matches!(payload, ToolPayload::Function { .. }) } + fn supports_default_function_tool_hooks(&self) -> bool { + false + } + fn post_tool_use_payload( &self, invocation: &ToolInvocation, diff --git a/codex-rs/core/src/tools/hook_names.rs b/codex-rs/core/src/tools/hook_names.rs index 9d3b6c2409ef..92ebe8aa56de 100644 --- a/codex-rs/core/src/tools/hook_names.rs +++ b/codex-rs/core/src/tools/hook_names.rs @@ -38,6 +38,18 @@ impl HookToolName { } } + /// Returns the hook identity for spawning sub-agents. + /// + /// The serialized name remains `spawn_agent`, while `Agent` is accepted as + /// a matcher alias for compatibility with hook configurations that describe + /// sub-agent creation using Claude Code-style names. + pub(crate) fn spawn_agent() -> Self { + Self { + name: "spawn_agent".to_string(), + matcher_aliases: vec!["Agent".to_string()], + } + } + /// Returns the hook identity historically used for shell-like tools. pub(crate) fn bash() -> Self { Self::new("Bash") diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index bac636132999..bb58c631f402 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -204,6 +204,7 @@ impl ToolCallRuntime { result: Box::new(AbortedToolOutput { message: Self::abort_message(call, secs), }), + model_visible_override: None, post_tool_use_payload: None, } } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 411ce1435964..482b1f590fe6 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -19,6 +19,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; +use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE; use crate::tools::hook_names::HookToolName; use crate::tools::lifecycle::notify_tool_finish; use crate::tools::lifecycle::notify_tool_start; @@ -26,6 +27,7 @@ use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::tools::tool_search_entry::ToolSearchInfo; use crate::util::error_or_panic; use codex_extension_api::ToolCallOutcome; +use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; use codex_tools::ToolName; @@ -64,14 +66,55 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { fn post_tool_use_payload( &self, - _invocation: &ToolInvocation, - _result: &dyn ToolOutput, + invocation: &ToolInvocation, + result: &dyn ToolOutput, ) -> Option { - None + if !self.supports_default_function_tool_hooks() { + return None; + } + + let ToolPayload::Function { arguments } = &invocation.payload else { + return None; + }; + + Some(PostToolUsePayload { + tool_name: function_hook_tool_name(invocation), + tool_use_id: result.post_tool_use_id(&invocation.call_id), + tool_input: result + .post_tool_use_input(&invocation.payload) + .unwrap_or_else(|| function_hook_tool_input(arguments)), + tool_response: result + .post_tool_use_response(&invocation.call_id, &invocation.payload) + .or_else(|| { + // Most function tools can expose their model-facing output + // as the hook response. Outputs with a more stable hook + // contract should override post_tool_use_response above. + let ResponseInputItem::FunctionCallOutput { + output: FunctionCallOutputPayload { body, .. }, + .. + } = result.to_response_item(&invocation.call_id, &invocation.payload) + else { + return None; + }; + + serde_json::to_value(body).ok() + })?, + }) } - fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option { - None + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + if !self.supports_default_function_tool_hooks() { + return None; + } + + let ToolPayload::Function { arguments } = &invocation.payload else { + return None; + }; + + Some(PreToolUsePayload { + tool_name: function_hook_tool_name(invocation), + tool_input: function_hook_tool_input(arguments), + }) } /// Rebuilds a tool invocation from hook-facing `tool_input`. @@ -80,14 +123,43 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { /// hook contract they expose from `pre_tool_use_payload`. fn with_updated_hook_input( &self, - _invocation: ToolInvocation, - _updated_input: Value, + invocation: ToolInvocation, + updated_input: Value, ) -> Result { + if self.supports_default_function_tool_hooks() { + let ToolPayload::Function { .. } = &invocation.payload else { + return Err(FunctionCallError::RespondToModel( + "hook input rewrite received unsupported function tool payload".to_string(), + )); + }; + + let arguments = serde_json::to_string(&updated_input).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to serialize rewritten {} arguments: {err}", + flat_tool_name(&invocation.tool_name) + )) + })?; + return Ok(ToolInvocation { + payload: ToolPayload::Function { arguments }, + ..invocation + }); + } + Err(FunctionCallError::RespondToModel( "tool does not support hook input rewriting".to_string(), )) } + /// Returns whether this tool uses the generic function-tool hook contract. + /// + /// Most local function tools expose their JSON arguments directly to hooks. + /// Tools with compatibility-specific hook contracts can override the hook + /// payload methods instead, while function tools that should not run hooks + /// can opt out here. + fn supports_default_function_tool_hooks(&self) -> bool { + true + } + /// Creates an optional consumer for streamed tool argument diffs. fn create_diff_consumer(&self) -> Option> { None @@ -111,6 +183,7 @@ pub(crate) struct AnyToolResult { pub(crate) call_id: String, pub(crate) payload: ToolPayload, pub(crate) result: Box, + pub(crate) model_visible_override: Option, pub(crate) post_tool_use_payload: Option, } @@ -120,9 +193,13 @@ impl AnyToolResult { call_id, payload, result, + model_visible_override, .. } = self; - result.to_response_item(&call_id, &payload) + model_visible_override.map_or_else( + || result.to_response_item(&call_id, &payload), + |output| output.to_response_item(&call_id, &payload), + ) } pub(crate) fn code_mode_result(self) -> serde_json::Value { @@ -234,6 +311,10 @@ impl CoreToolRuntime for ExposureOverride { .with_updated_hook_input(invocation, updated_input) } + fn supports_default_function_tool_hooks(&self) -> bool { + self.handler.supports_default_function_tool_hooks() + } + fn telemetry_tags<'a>( &'a self, invocation: &'a ToolInvocation, @@ -539,7 +620,7 @@ impl ToolRegistry { if let Some(replacement_text) = replacement_text { let mut guard = response_cell.lock().await; if let Some(result) = guard.as_mut() { - result.result = Box::new(FunctionToolOutput::from_text( + result.model_visible_override = Some(FunctionToolOutput::from_text( replacement_text, /*success*/ None, )); @@ -614,10 +695,32 @@ async fn handle_any_tool( call_id, payload, result: output, + model_visible_override: None, post_tool_use_payload, }) } +fn function_hook_tool_name(invocation: &ToolInvocation) -> HookToolName { + if invocation.tool_name.name == "spawn_agent" + && matches!( + invocation.tool_name.namespace.as_deref(), + None | Some(MULTI_AGENT_V1_NAMESPACE) + ) + { + return HookToolName::spawn_agent(); + } + + HookToolName::new(flat_tool_name(&invocation.tool_name).into_owned()) +} + +fn function_hook_tool_input(arguments: &str) -> Value { + if arguments.trim().is_empty() { + return Value::Object(serde_json::Map::new()); + } + + serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string())) +} + fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String { match payload { ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"), diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 8517730d32c9..d2fd14ab0ddc 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -172,6 +172,192 @@ fn handler_looks_up_namespaced_aliases_explicitly() { ); } +#[tokio::test] +async fn function_tools_expose_default_hook_payloads_and_rewrites() -> anyhow::Result<()> { + let (session, turn) = crate::session::tests::make_session_and_context().await; + let tool_name = codex_tools::ToolName::namespaced("functions.", "echo"); + let handler = TestHandler { + tool_name: tool_name.clone(), + }; + let invocation = ToolInvocation { + payload: ToolPayload::Function { + arguments: serde_json::json!({ "message": "hello" }).to_string(), + }, + ..test_invocation(Arc::new(session), Arc::new(turn), "call-1", tool_name) + }; + let output = + crate::tools::context::FunctionToolOutput::from_text("echoed".to_string(), Some(true)); + + assert_eq!( + handler.pre_tool_use_payload(&invocation), + Some(PreToolUsePayload { + tool_name: HookToolName::new("functions.echo"), + tool_input: serde_json::json!({ "message": "hello" }), + }) + ); + assert_eq!( + handler.post_tool_use_payload(&invocation, &output), + Some(PostToolUsePayload { + tool_name: HookToolName::new("functions.echo"), + tool_use_id: "call-1".to_string(), + tool_input: serde_json::json!({ "message": "hello" }), + tool_response: serde_json::json!("echoed"), + }) + ); + + let invocation = handler + .with_updated_hook_input(invocation, serde_json::json!({ "message": "rewritten" }))?; + let ToolPayload::Function { arguments } = invocation.payload else { + panic!("generic rewritten function payload should remain function-shaped"); + }; + assert_eq!( + serde_json::from_str::(&arguments)?, + serde_json::json!({ "message": "rewritten" }) + ); + + Ok(()) +} + +#[tokio::test] +async fn function_hook_input_defaults_empty_arguments_to_object() { + let (session, turn) = crate::session::tests::make_session_and_context().await; + let tool_name = codex_tools::ToolName::plain("echo"); + let handler = TestHandler { + tool_name: tool_name.clone(), + }; + let invocation = ToolInvocation { + payload: ToolPayload::Function { + arguments: " ".to_string(), + }, + ..test_invocation(Arc::new(session), Arc::new(turn), "call-1", tool_name) + }; + + assert_eq!( + handler.pre_tool_use_payload(&invocation), + Some(PreToolUsePayload { + tool_name: HookToolName::new("echo"), + tool_input: serde_json::json!({}), + }) + ); +} + +#[tokio::test] +async fn spawn_agent_function_tools_use_agent_matcher_alias() { + let (session, turn) = crate::session::tests::make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + + let hook_payloads = [ + codex_tools::ToolName::plain("spawn_agent"), + codex_tools::ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent"), + ] + .into_iter() + .map(|tool_name| { + let handler = TestHandler { + tool_name: tool_name.clone(), + }; + let invocation = ToolInvocation { + payload: ToolPayload::Function { + arguments: serde_json::json!({ "message": "inspect this repo" }).to_string(), + }, + ..test_invocation(Arc::clone(&session), Arc::clone(&turn), "call-1", tool_name) + }; + handler.pre_tool_use_payload(&invocation) + }) + .collect::>(); + + assert_eq!( + hook_payloads, + vec![ + Some(PreToolUsePayload { + tool_name: HookToolName::spawn_agent(), + tool_input: serde_json::json!({ "message": "inspect this repo" }), + }), + Some(PreToolUsePayload { + tool_name: HookToolName::spawn_agent(), + tool_input: serde_json::json!({ "message": "inspect this repo" }), + }), + ] + ); +} + +#[tokio::test] +async fn opted_out_function_tools_do_not_expose_default_hook_payloads() { + let (session, turn) = crate::session::tests::make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + let output = crate::tools::context::FunctionToolOutput::from_text("ok".to_string(), Some(true)); + + let request_permissions = crate::tools::handlers::RequestPermissionsHandler; + let request_permissions_invocation = test_invocation( + Arc::clone(&session), + Arc::clone(&turn), + "request-permissions-call", + request_permissions.tool_name(), + ); + assert_eq!( + request_permissions.pre_tool_use_payload(&request_permissions_invocation), + None + ); + assert_eq!( + request_permissions.post_tool_use_payload(&request_permissions_invocation, &output), + None + ); + + let wait = crate::tools::handlers::CodeModeWaitHandler; + let wait_invocation = test_invocation(session, turn, "wait-call", wait.tool_name()); + assert_eq!(wait.pre_tool_use_payload(&wait_invocation), None); + assert_eq!(wait.post_tool_use_payload(&wait_invocation, &output), None); +} + +#[test] +fn model_visible_override_keeps_code_mode_result_typed() { + let result = AnyToolResult { + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + result: Box::new(codex_tools::JsonToolOutput::new( + serde_json::json!({ "typed": true }), + )), + model_visible_override: Some(crate::tools::context::FunctionToolOutput::from_text( + "hook feedback".to_string(), + /*success*/ None, + )), + post_tool_use_payload: None, + }; + + assert_eq!( + result.into_response(), + ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: codex_protocol::models::FunctionCallOutputPayload::from_text( + "hook feedback".to_string() + ), + } + ); + + let result = AnyToolResult { + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + result: Box::new(codex_tools::JsonToolOutput::new( + serde_json::json!({ "typed": true }), + )), + model_visible_override: Some(crate::tools::context::FunctionToolOutput::from_text( + "hook feedback".to_string(), + /*success*/ None, + )), + post_tool_use_payload: None, + }; + + assert_eq!( + result.code_mode_result(), + serde_json::json!({ "typed": true }) + ); +} + #[tokio::test] async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> { let (mut session, turn) = crate::session::tests::make_session_and_context().await; diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index ee1826ceb9fe..2f69de1084c2 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3463,42 +3463,35 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> { } #[tokio::test] -async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> { +async fn pre_tool_use_blocks_local_function_tool_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "pretooluse-update-plan"; - let args = serde_json::json!({ - "plan": [{ - "step": "watch the tide", - "status": "pending", - }] - }); + let call_id = "pretooluse-local-function-tool"; + let args = serde_json::json!({}); let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), - core_test_support::responses::ev_function_call( - call_id, - "update_plan", - &serde_json::to_string(&args)?, - ), + ev_function_call(call_id, "test_sync_tool", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ ev_response_created("resp-2"), - ev_assistant_message("msg-1", "plan updated"), + ev_assistant_message("msg-1", "local function hook blocked it"), ev_completed("resp-2"), ]), ], ) .await; + let reason = "blocked local function pre hook"; let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") .with_pre_build_hook(|home| { if let Err(error) = - write_pre_tool_use_hook(home, /*matcher*/ None, "json_deny", "should not fire") + write_pre_tool_use_hook(home, Some("^test_sync_tool$"), "json_deny", reason) { panic!("failed to write pre tool use hook test fixture: {error}"); } @@ -3506,7 +3499,8 @@ async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> { .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; - test.submit_turn("update the plan").await?; + test.submit_turn("call the local function tool with the pre hook") + .await?; let requests = responses.requests(); assert_eq!(requests.len(), 2); @@ -3514,16 +3508,154 @@ async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> { let output = output_item .get("output") .and_then(Value::as_str) - .expect("update plan output string"); + .expect("blocked local function tool output string"); assert!( - !output.contains("should not fire"), - "non-shell tool output should not be blocked by PreToolUse", + output.contains(&format!( + "Tool call blocked by PreToolUse hook: {reason}. Tool: test_sync_tool" + )), + "blocked local function output should surface the hook reason and tool name", ); - let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl"); + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse"); + assert_eq!(hook_inputs[0]["tool_name"], "test_sync_tool"); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"], args); + + Ok(()) +} + +#[tokio::test] +async fn pre_tool_use_rewrites_local_function_tool_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-local-function-tool-rewrite"; + let original_args = serde_json::json!({ + "barrier": { + "id": "pretooluse-local-function-invalid-barrier", + "participants": 0, + } + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + "test_sync_tool", + &serde_json::to_string(&original_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "local function hook rewrote it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let updated_input = serde_json::json!({}); + let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_pre_build_hook(move |home| { + if let Err(error) = + write_updating_pre_tool_use_hook(home, "^test_sync_tool$", &updated_input) + { + panic!("failed to write updating pre tool use hook test fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("call the local function tool with the pre hook rewrite") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("rewritten local function tool output string"); + assert_eq!(output, "ok"); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["tool_input"], original_args); + + Ok(()) +} + +#[tokio::test] +async fn post_tool_use_records_local_function_tool_payload_and_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "posttooluse-local-function-tool"; + let args = serde_json::json!({}); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "test_sync_tool", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "local function post hook context observed"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let post_context = "Remember the local function post-tool note."; + let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_pre_build_hook(|home| { + if let Err(error) = + write_post_tool_use_hook(home, Some("^test_sync_tool$"), "context", post_context) + { + panic!("failed to write post tool use hook test fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("call the local function tool with the post hook") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); assert!( - !hook_log_path.exists(), - "plan tool should not trigger pre tool use hooks", + requests[1] + .message_input_texts("developer") + .contains(&post_context.to_string()), + "follow-up request should include local function post tool use context", + ); + assert_eq!( + requests[1] + .function_call_output(call_id) + .get("output") + .and_then(Value::as_str), + Some("ok"), + ); + + let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PostToolUse"); + assert_eq!(hook_inputs[0]["tool_name"], "test_sync_tool"); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"], args); + assert_eq!( + hook_inputs[0]["tool_response"], + Value::String("ok".to_string()) ); Ok(()) @@ -4133,73 +4265,3 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<( Ok(()) } - -#[tokio::test] -async fn post_tool_use_does_not_fire_for_plan_tool() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let call_id = "posttooluse-update-plan"; - let args = serde_json::json!({ - "plan": [{ - "step": "watch the tide", - "status": "pending", - }] - }); - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - core_test_support::responses::ev_function_call( - call_id, - "update_plan", - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "plan updated"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - let mut builder = test_codex() - .with_pre_build_hook(|home| { - if let Err(error) = write_post_tool_use_hook( - home, - /*matcher*/ None, - "decision_block", - "should not fire", - ) { - panic!("failed to write post tool use hook test fixture: {error}"); - } - }) - .with_config(trust_discovered_hooks); - let test = builder.build(&server).await?; - - test.submit_turn("update the plan").await?; - - let requests = responses.requests(); - assert_eq!(requests.len(), 2); - let output_item = requests[1].function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("update plan output string"); - assert!( - !output.contains("should not fire"), - "non-shell tool output should not be affected by PostToolUse", - ); - - let hook_log_path = test.codex_home_path().join("post_tool_use_hook_log.jsonl"); - assert!( - !hook_log_path.exists(), - "plan tool should not trigger post tool use hooks", - ); - - Ok(()) -}