Compare commits

...

22 Commits

Author SHA1 Message Date
Abhinav Vedmala
9fcee0af9a Merge origin/main into abhinav/spawn-agent-hook-alias 2026-05-21 15:40:49 -07:00
Abhinav Vedmala
d9a7b5668c Add Agent matcher alias for spawn_agent 2026-05-20 17:29:45 -07:00
Abhinav Vedmala
c16afceda6 Inline function tool hook defaults 2026-05-20 17:18:11 -07:00
Abhinav Vedmala
2f0db69ede Cover function hook opt outs 2026-05-20 17:18:11 -07:00
Abhinav Vedmala
579781d011 Cover local function tool hooks 2026-05-20 17:18:10 -07:00
Abhinav Vedmala
754ce7538d Remove stale plan hook exclusions 2026-05-20 17:18:10 -07:00
Abhinav Vedmala
f6f6721555 Update plan hook coverage 2026-05-20 17:18:10 -07:00
Abhinav Vedmala
26c2104698 Default hooks for function tools 2026-05-20 17:18:10 -07:00
Abhinav Vedmala
0ed016d8c9 Fix hooks schema arg comments 2026-05-20 16:03:02 -07:00
Abhinav Vedmala
08f7c920dd Fix subagent hook fixture arg comments 2026-05-20 15:43:55 -07:00
Abhinav Vedmala
fd3a862118 Merge remote-tracking branch 'origin/main' into abhinav/subagent-hook-context-stack
# Conflicts:
#	codex-rs/core/src/hook_runtime.rs
#	codex-rs/core/tests/suite/subagent_notifications.rs
2026-05-20 15:24:00 -07:00
Abhinav Vedmala
15fed46b20 Merge remote-tracking branch 'origin/abhinav/subagent-stop-stack' into abhinav/subagent-hook-context-stack
# Conflicts:
#	codex-rs/core/src/hook_runtime.rs
#	codex-rs/core/tests/suite/subagent_notifications.rs
2026-05-20 10:14:44 -07:00
Abhinav Vedmala
a2c6e1b6b0 Merge main into abhinav/subagent-stop-stack 2026-05-19 13:40:03 -07:00
Abhinav Vedmala
94cbf56ad0 Clarify stop hook target resolution 2026-05-15 16:33:06 -07:00
Abhinav Vedmala
ce0d7c465c Remove stop hook kind helper 2026-05-15 16:28:08 -07:00
Abhinav Vedmala
579bb3b015 Simplify stop hook parsing 2026-05-15 16:10:26 -07:00
Abhinav Vedmala
11f3142274 Merge subagent hooks base into stop stack 2026-05-15 15:49:11 -07:00
Abhinav Vedmala
8dfeb972c7 Address subagent start hook review feedback 2026-05-15 22:00:00 +00:00
Abhinav Vedmala
991bc308ab Wait for subagent hook logs in test 2026-05-15 11:46:41 -07:00
Abhinav Vedmala
b2e51b85ee Add subagent identity to hook inputs 2026-05-15 18:42:10 +00:00
Abhinav Vedmala
48110f91dc Add SubagentStop hook 2026-05-15 18:01:57 +00:00
Abhinav Vedmala
bd8ee83848 Add SubagentStart hook 2026-05-15 17:30:36 +00:00
10 changed files with 485 additions and 149 deletions

View File

@@ -110,4 +110,8 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
}
}
impl CoreToolRuntime for CodeModeWaitHandler {}
impl CoreToolRuntime for CodeModeWaitHandler {
fn supports_default_function_tool_hooks(&self) -> bool {
false
}
}

View File

@@ -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<dyn codex_tools::ToolExecutor<ExtensionToolCall>>);
@@ -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<PreToolUsePayload> {
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<PostToolUsePayload> {
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;

View File

@@ -134,7 +134,6 @@ impl CoreToolRuntime for McpHandler {
tags
})
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
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(),

View File

@@ -82,4 +82,8 @@ impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
}
}
impl CoreToolRuntime for RequestPermissionsHandler {}
impl CoreToolRuntime for RequestPermissionsHandler {
fn supports_default_function_tool_hooks(&self) -> bool {
false
}
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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,
}
}

View File

@@ -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<ToolInvocation> {
fn post_tool_use_payload(
&self,
_invocation: &ToolInvocation,
_result: &dyn ToolOutput,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
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<PreToolUsePayload> {
None
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
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<ToolInvocation> {
/// 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<ToolInvocation, FunctionCallError> {
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<Box<dyn ToolArgumentDiffConsumer>> {
None
@@ -111,6 +183,7 @@ pub(crate) struct AnyToolResult {
pub(crate) call_id: String,
pub(crate) payload: ToolPayload,
pub(crate) result: Box<dyn ToolOutput>,
pub(crate) model_visible_override: Option<FunctionToolOutput>,
pub(crate) post_tool_use_payload: Option<PostToolUsePayload>,
}
@@ -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}"),

View File

@@ -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::<serde_json::Value>(&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::<Vec<_>>();
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;

View File

@@ -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(())
}