codex: add dynamic tool hook support

This commit is contained in:
Andrei Eternal
2026-04-29 22:23:25 -07:00
parent bacaeb7060
commit c98b0d1925
9 changed files with 1122 additions and 20 deletions

View File

@@ -95,7 +95,7 @@ pub(crate) async fn handle_mcp_tool_call(
call_id: String,
server: String,
tool_name: String,
hook_tool_name: String,
hook_tool_name: HookToolName,
arguments: String,
) -> HandledMcpToolCall {
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
@@ -198,7 +198,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context,
&call_id,
&invocation,
&hook_tool_name,
hook_tool_name.name(),
metadata.as_ref(),
approval_mode,
)
@@ -1009,13 +1009,13 @@ async fn maybe_request_mcp_tool_approval(
sess,
turn_context,
call_id,
PermissionRequestPayload {
tool_name: HookToolName::new(hook_tool_name),
tool_input: invocation
PermissionRequestPayload::new(
HookToolName::new(hook_tool_name),
invocation
.arguments
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
},
),
)
.await
{

View File

@@ -3,8 +3,12 @@ use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
@@ -27,6 +31,27 @@ impl ToolHandler for DynamicToolHandler {
ToolKind::Function
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
Some(PreToolUsePayload {
tool_name: HookToolName::for_function_tool(&invocation.tool_name),
tool_input: dynamic_tool_input(invocation).ok()?,
})
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
Some(PostToolUsePayload {
tool_name: HookToolName::for_function_tool(&invocation.tool_name),
tool_use_id: invocation.call_id.clone(),
tool_input: dynamic_tool_input(invocation).ok()?,
tool_response: result
.post_tool_use_response(&invocation.call_id, &invocation.payload)?,
})
}
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
true
}
@@ -67,7 +92,34 @@ impl ToolHandler for DynamicToolHandler {
.into_iter()
.map(FunctionCallOutputContentItem::from)
.collect::<Vec<_>>();
Ok(FunctionToolOutput::from_content(body, Some(success)))
Ok(FunctionToolOutput {
post_tool_use_response: Some(dynamic_tool_post_tool_use_response(&body)?),
body,
success: Some(success),
})
}
}
fn dynamic_tool_input(invocation: &ToolInvocation) -> Result<Value, FunctionCallError> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"dynamic tool handler received unsupported payload".to_string(),
));
};
parse_arguments(arguments)
}
fn dynamic_tool_post_tool_use_response(
body: &[FunctionCallOutputContentItem],
) -> Result<Value, FunctionCallError> {
match body {
[FunctionCallOutputContentItem::InputText { text }] => Ok(Value::String(text.clone())),
_ => serde_json::to_value(body).map_err(|error| {
FunctionCallError::RespondToModel(format!(
"failed to serialize dynamic tool response for PostToolUse: {error}"
))
}),
}
}
@@ -140,3 +192,122 @@ async fn request_dynamic_tool(
response
}
#[cfg(test)]
mod tests {
use super::DynamicToolHandler;
use super::dynamic_tool_post_tool_use_response;
use crate::session::tests::make_session_and_context;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_tools::ToolName;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
async fn dynamic_invocation(
tool_name: ToolName,
arguments: serde_json::Value,
) -> ToolInvocation {
let (session, turn) = make_session_and_context().await;
ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-dynamic".to_string(),
tool_name,
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: arguments.to_string(),
},
}
}
#[tokio::test]
async fn dynamic_pre_tool_use_payload_uses_plain_tool_name() {
let invocation =
dynamic_invocation(ToolName::plain("automation_update"), json!({"id": 1})).await;
assert_eq!(
DynamicToolHandler.pre_tool_use_payload(&invocation),
Some(PreToolUsePayload {
tool_name: HookToolName::new("automation_update"),
tool_input: json!({ "id": 1 }),
})
);
}
#[tokio::test]
async fn dynamic_post_tool_use_payload_uses_namespaced_hook_name() {
let invocation = dynamic_invocation(
ToolName::namespaced("codex_app", "automation_update"),
json!({ "job": "sync" }),
)
.await;
let output = FunctionToolOutput {
body: vec![FunctionCallOutputContentItem::InputText {
text: "ok".to_string(),
}],
success: Some(true),
post_tool_use_response: Some(json!("ok")),
};
assert_eq!(
DynamicToolHandler.post_tool_use_payload(&invocation, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::new("dynamic__codex_app__automation_update"),
tool_use_id: "call-dynamic".to_string(),
tool_input: json!({ "job": "sync" }),
tool_response: json!("ok"),
})
);
}
#[test]
fn dynamic_post_tool_use_response_uses_text_for_single_text_item() {
assert_eq!(
dynamic_tool_post_tool_use_response(&[FunctionCallOutputContentItem::InputText {
text: "done".to_string(),
}]),
Ok(json!("done"))
);
}
#[test]
fn dynamic_post_tool_use_response_uses_content_items_for_mixed_output() {
let response = dynamic_tool_post_tool_use_response(&[
FunctionCallOutputContentItem::InputText {
text: "done".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "https://example.com/image.png".to_string(),
detail: None,
},
])
.expect("serialize mixed dynamic tool output");
assert_eq!(
response,
json!([
{
"type": "input_text",
"text": "done",
},
{
"type": "input_image",
"image_url": "https://example.com/image.png",
}
])
);
}
}

View File

@@ -29,7 +29,7 @@ impl ToolHandler for McpHandler {
};
Some(PreToolUsePayload {
tool_name: HookToolName::new(invocation.tool_name.display()),
tool_name: HookToolName::for_function_tool(&invocation.tool_name),
tool_input: mcp_hook_tool_input(raw_arguments),
})
}
@@ -46,7 +46,7 @@ impl ToolHandler for McpHandler {
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::new(invocation.tool_name.display()),
tool_name: HookToolName::for_function_tool(&invocation.tool_name),
tool_use_id: invocation.call_id.clone(),
tool_input: result.tool_input.clone(),
tool_response,
@@ -78,6 +78,7 @@ impl ToolHandler for McpHandler {
let (server, tool, raw_arguments) = payload;
let arguments_str = raw_arguments;
let hook_tool_name = HookToolName::for_function_tool(&model_tool_name);
let started = Instant::now();
let result = handle_mcp_tool_call(
@@ -86,7 +87,7 @@ impl ToolHandler for McpHandler {
call_id.clone(),
server,
tool,
model_tool_name.display(),
hook_tool_name,
arguments_str,
)
.await;
@@ -147,7 +148,10 @@ mod tests {
payload,
}),
Some(PreToolUsePayload {
tool_name: HookToolName::new("mcp__memory__create_entities"),
tool_name: HookToolName::for_function_tool(&codex_tools::ToolName::namespaced(
"mcp__memory__",
"create_entities",
)),
tool_input: json!({
"entities": [{
"name": "Ada",
@@ -198,7 +202,10 @@ mod tests {
assert_eq!(
McpHandler.post_tool_use_payload(&invocation, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::new("mcp__filesystem__read_file"),
tool_name: HookToolName::for_function_tool(&codex_tools::ToolName::namespaced(
"mcp__filesystem__",
"read_file",
)),
tool_use_id: "call-mcp-post".to_string(),
tool_input: json!({
"path": {

View File

@@ -5,6 +5,8 @@
//! concepts together prevents handlers from accidentally serializing a
//! compatibility alias, such as `Write`, as the stable hook payload name.
use codex_tools::ToolName;
/// Identifies a tool in hook payloads and hook matcher selection.
///
/// `name` is the canonical value serialized into hook stdin. Matcher aliases are
@@ -25,6 +27,21 @@ impl HookToolName {
}
}
/// Builds the canonical hook-facing identity for function-style tools that
/// do not need Claude-compatibility aliases.
///
/// MCP tool names already use a stable fully qualified `mcp__...__tool`
/// form, so we preserve them verbatim. Other namespaced tools use the
/// `dynamic__namespace__tool` form so hooks can wildcard an entire dynamic
/// namespace without colliding with plain tool names.
pub(crate) fn for_function_tool(tool_name: &ToolName) -> Self {
match tool_name.namespace.as_deref() {
Some(namespace) if namespace.starts_with("mcp__") => Self::new(tool_name.display()),
Some(namespace) => Self::new(format!("dynamic__{namespace}__{}", tool_name.name)),
None => Self::new(tool_name.name.clone()),
}
}
/// Returns the hook identity for file edits performed through `apply_patch`.
///
/// The serialized name remains `apply_patch` so logs and policies can key
@@ -53,3 +70,40 @@ impl HookToolName {
&self.matcher_aliases
}
}
#[cfg(test)]
mod tests {
use super::HookToolName;
use codex_tools::ToolName;
use pretty_assertions::assert_eq;
#[test]
fn for_function_tool_keeps_plain_tool_names() {
assert_eq!(
HookToolName::for_function_tool(&ToolName::plain("tool_search")),
HookToolName::new("tool_search"),
);
}
#[test]
fn for_function_tool_keeps_mcp_names_stable() {
assert_eq!(
HookToolName::for_function_tool(&ToolName::namespaced(
"mcp__memory__",
"create_entities",
)),
HookToolName::new("mcp__memory__create_entities"),
);
}
#[test]
fn for_function_tool_prefixes_dynamic_namespaces() {
assert_eq!(
HookToolName::for_function_tool(&ToolName::namespaced(
"codex_app",
"automation_update",
)),
HookToolName::new("dynamic__codex_app__automation_update"),
);
}
}

View File

@@ -177,10 +177,10 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
&self,
req: &ApplyPatchRequest,
) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload {
tool_name: HookToolName::apply_patch(),
tool_input: serde_json::json!({ "command": req.action.patch }),
})
Some(PermissionRequestPayload::new(
HookToolName::apply_patch(),
serde_json::json!({ "command": req.action.patch }),
))
}
}

View File

@@ -139,6 +139,13 @@ pub(crate) struct PermissionRequestPayload {
}
impl PermissionRequestPayload {
pub(crate) fn new(tool_name: HookToolName, tool_input: serde_json::Value) -> Self {
Self {
tool_name,
tool_input,
}
}
pub(crate) fn bash(command: String, description: Option<String>) -> Self {
let mut tool_input = serde_json::Map::new();
tool_input.insert("command".to_string(), serde_json::Value::String(command));
@@ -149,10 +156,7 @@ impl PermissionRequestPayload {
);
}
Self {
tool_name: HookToolName::bash(),
tool_input: serde_json::Value::Object(tool_input),
}
Self::new(HookToolName::bash(), serde_json::Value::Object(tool_input))
}
}

View File

@@ -0,0 +1,839 @@
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use codex_features::Feature;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use wiremock::MockServer;
const DYNAMIC_TOOL_NAME: &str = "automation_update";
const DYNAMIC_NAMESPACE: &str = "codex_app";
const DYNAMIC_HOOK_NAME: &str = "dynamic__codex_app__automation_update";
fn dynamic_tool(namespace: Option<&str>, name: &str) -> DynamicToolSpec {
DynamicToolSpec {
namespace: namespace.map(str::to_string),
name: name.to_string(),
description: format!("Dynamic hook test tool for {name}."),
input_schema: json!({
"type": "object",
"properties": {
"job": { "type": "string" }
},
"required": ["job"],
"additionalProperties": false,
}),
defer_loading: false,
}
}
fn write_pre_tool_use_hook(home: &Path, matcher: &str, reason: &str) -> Result<()> {
let script_path = home.join("pre_tool_use_hook.py");
let log_path = home.join("pre_tool_use_hook_log.jsonl");
let matcher_json = serde_json::to_string(matcher).context("serialize pre matcher")?;
let reason_json = serde_json::to_string(reason).context("serialize pre reason")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
matcher = {matcher_json}
reason = {reason_json}
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}}
}}))
"#,
log_path = log_path.display(),
matcher_json = matcher_json,
reason_json = reason_json,
);
let hooks = json!({
"hooks": {
"PreToolUse": [{
"matcher": matcher,
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running dynamic pre tool use hook",
}]
}]
}
});
fs::write(&script_path, script).context("write dynamic pre tool use hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_post_tool_use_hook(
home: &Path,
matcher: &str,
additional_context: Option<&str>,
) -> Result<()> {
let script_path = home.join("post_tool_use_hook.py");
let log_path = home.join("post_tool_use_hook_log.jsonl");
let additional_context_json =
serde_json::to_string(&additional_context).context("serialize post context")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
additional_context = {additional_context_json}
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
if additional_context is not None:
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PostToolUse",
"additionalContext": additional_context
}}
}}))
"#,
log_path = log_path.display(),
additional_context_json = additional_context_json,
);
let hooks = json!({
"hooks": {
"PostToolUse": [{
"matcher": matcher,
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running dynamic post tool use hook",
}]
}]
}
});
fs::write(&script_path, script).context("write dynamic post tool use hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_permission_request_hook(home: &Path, matcher: &str) -> Result<()> {
let script_path = home.join("permission_request_hook.py");
let log_path = home.join("permission_request_hook_log.jsonl");
let script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{
"behavior": "allow"
}}
}}
}}))
"#,
log_path = log_path.display(),
);
let hooks = json!({
"hooks": {
"PermissionRequest": [{
"matcher": matcher,
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running dynamic permission request hook",
}]
}]
}
});
fs::write(&script_path, script).context("write dynamic permission request hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn read_hook_inputs(home: &Path, log_name: &str) -> Result<Vec<Value>> {
let log_path = home.join(log_name);
let contents = match fs::read_to_string(&log_path) {
Ok(contents) => contents,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err).with_context(|| format!("read {}", log_path.display())),
};
contents
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse hook log line"))
.collect()
}
async fn build_dynamic_tool_test<F>(
server: &MockServer,
dynamic_tools: Vec<DynamicToolSpec>,
pre_build_hook: F,
) -> Result<TestCodex>
where
F: FnOnce(&Path) + Send + 'static,
{
let base_test = test_codex()
.with_pre_build_hook(pre_build_hook)
.with_config(|config| {
if let Err(err) = config.features.enable(Feature::CodexHooks) {
panic!("test config should allow enabling codex hooks: {err}");
}
})
.build(server)
.await?;
let new_thread = base_test
.thread_manager
.start_thread_with_tools(
base_test.config.clone(),
codex_core::thread_store_from_config(&base_test.config),
dynamic_tools,
/*persist_extended_history*/ false,
)
.await?;
let mut test = base_test;
test.codex = new_thread.thread;
test.session_configured = new_thread.session_configured;
Ok(test)
}
async fn submit_dynamic_tool_turn(
test: &TestCodex,
prompt: &str,
approval_policy: AskForApproval,
) -> Result<String> {
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
let session_model = test.session_configured.model.clone();
test.codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: prompt.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.config.cwd.to_path_buf(),
approval_policy,
approvals_reviewer: None,
sandbox_policy,
permission_profile,
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
Ok(wait_for_event_match(&test.codex, |event| match event {
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
_ => None,
})
.await)
}
async fn wait_for_turn_to_finish_without_dynamic_request(
test: &TestCodex,
turn_id: &str,
) -> Result<()> {
tokio::time::timeout(Duration::from_secs(20), async {
loop {
let event = test.codex.next_event().await.context("next event")?;
match event.msg {
EventMsg::DynamicToolCallRequest(request) => {
anyhow::bail!(
"unexpected DynamicToolCallRequest for {} {:?}",
request.tool,
request.namespace
);
}
EventMsg::TurnComplete(event) if event.turn_id == turn_id => return Ok(()),
EventMsg::TurnAborted(event) if event.turn_id.as_deref() == Some(turn_id) => {
return Ok(());
}
_ => {}
}
}
})
.await
.context("timeout waiting for turn to finish")?
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_plain_dynamic_tool_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-dynamic-plain";
let arguments = json!({ "job": "plain" }).to_string();
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, DYNAMIC_TOOL_NAME, &arguments),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "plain dynamic hook blocked it"),
ev_completed("resp-2"),
]),
],
)
.await;
let block_reason = "blocked plain dynamic tool";
let test = build_dynamic_tool_test(
&server,
vec![dynamic_tool(/*namespace*/ None, DYNAMIC_TOOL_NAME)],
move |home| {
if let Err(err) = write_pre_tool_use_hook(home, DYNAMIC_TOOL_NAME, block_reason) {
panic!("failed to write plain dynamic pre hook: {err}");
}
},
)
.await?;
let turn_id = submit_dynamic_tool_turn(
&test,
"call the plain dynamic tool with the pre hook",
AskForApproval::Never,
)
.await?;
wait_for_turn_to_finish_without_dynamic_request(&test, &turn_id).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("blocked plain dynamic tool output");
assert!(
output.contains(&format!(
"Tool call blocked by PreToolUse hook: {block_reason}. Tool: {DYNAMIC_TOOL_NAME}"
)),
"blocked plain dynamic tool output should mention the reason and tool name",
);
let hook_inputs = read_hook_inputs(test.codex_home_path(), "pre_tool_use_hook_log.jsonl")?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
json!({
"hook_event_name": hook_inputs[0]["hook_event_name"],
"tool_name": hook_inputs[0]["tool_name"],
"tool_use_id": hook_inputs[0]["tool_use_id"],
"tool_input": hook_inputs[0]["tool_input"],
}),
json!({
"hook_event_name": "PreToolUse",
"tool_name": DYNAMIC_TOOL_NAME,
"tool_use_id": call_id,
"tool_input": { "job": "plain" },
}),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_namespaced_dynamic_tool_with_dynamic_matcher() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-dynamic-namespaced";
let arguments = json!({ "job": "namespaced" }).to_string();
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
DYNAMIC_NAMESPACE,
DYNAMIC_TOOL_NAME,
&arguments,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "namespaced dynamic hook blocked it"),
ev_completed("resp-2"),
]),
],
)
.await;
let block_reason = "blocked namespaced dynamic tool";
let test = build_dynamic_tool_test(
&server,
vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)],
move |home| {
if let Err(err) = write_pre_tool_use_hook(home, DYNAMIC_HOOK_NAME, block_reason) {
panic!("failed to write namespaced dynamic pre hook: {err}");
}
},
)
.await?;
let turn_id = submit_dynamic_tool_turn(
&test,
"call the namespaced dynamic tool with the pre hook",
AskForApproval::Never,
)
.await?;
wait_for_turn_to_finish_without_dynamic_request(&test, &turn_id).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("blocked namespaced dynamic tool output");
assert!(
output.contains(&format!(
"Tool call blocked by PreToolUse hook: {block_reason}. Tool: {DYNAMIC_HOOK_NAME}"
)),
"blocked namespaced dynamic tool output should mention the namespaced hook name",
);
let hook_inputs = read_hook_inputs(test.codex_home_path(), "pre_tool_use_hook_log.jsonl")?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
json!({
"hook_event_name": hook_inputs[0]["hook_event_name"],
"tool_name": hook_inputs[0]["tool_name"],
"tool_use_id": hook_inputs[0]["tool_use_id"],
"tool_input": hook_inputs[0]["tool_input"],
}),
json!({
"hook_event_name": "PreToolUse",
"tool_name": DYNAMIC_HOOK_NAME,
"tool_use_id": call_id,
"tool_input": { "job": "namespaced" },
}),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn post_tool_use_records_namespaced_dynamic_payload_and_context() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-dynamic-namespaced";
let arguments = json!({ "job": "post" }).to_string();
let call_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
DYNAMIC_NAMESPACE,
DYNAMIC_TOOL_NAME,
&arguments,
),
ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "dynamic post hook context observed"),
ev_completed("resp-2"),
]),
)
.await;
let post_context = "Remember the dynamic post-tool note.";
let test = build_dynamic_tool_test(
&server,
vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)],
move |home| {
if let Err(err) = write_post_tool_use_hook(home, DYNAMIC_HOOK_NAME, Some(post_context))
{
panic!("failed to write namespaced dynamic post hook: {err}");
}
},
)
.await?;
let turn_id = submit_dynamic_tool_turn(
&test,
"call the namespaced dynamic tool with the post hook",
AskForApproval::Never,
)
.await?;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::DynamicToolCallRequest(request) => Some(request.clone()),
_ => None,
})
.await;
assert_eq!(request.namespace.as_deref(), Some(DYNAMIC_NAMESPACE));
assert_eq!(request.tool, DYNAMIC_TOOL_NAME);
assert_eq!(request.arguments, json!({ "job": "post" }));
let content_items = vec![
DynamicToolCallOutputContentItem::InputText {
text: "done".to_string(),
},
DynamicToolCallOutputContentItem::InputImage {
image_url: "https://example.com/dynamic.png".to_string(),
},
];
test.codex
.submit(Op::DynamicToolResponse {
id: request.call_id.clone(),
response: DynamicToolResponse {
content_items: content_items.clone(),
success: true,
},
})
.await?;
wait_for_event(&test.codex, |event| match event {
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
_ => false,
})
.await;
call_mock.single_request();
let final_request = final_mock.single_request();
assert!(
final_request
.message_input_texts("developer")
.contains(&post_context.to_string()),
"follow-up request should include dynamic post tool use additional context",
);
assert_eq!(
final_request.function_call_output(call_id)["output"],
json!([
{
"type": "input_text",
"text": "done",
},
{
"type": "input_image",
"image_url": "https://example.com/dynamic.png",
"detail": "high",
}
]),
);
let hook_inputs = read_hook_inputs(test.codex_home_path(), "post_tool_use_hook_log.jsonl")?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
json!({
"hook_event_name": hook_inputs[0]["hook_event_name"],
"tool_name": hook_inputs[0]["tool_name"],
"tool_use_id": hook_inputs[0]["tool_use_id"],
"tool_input": hook_inputs[0]["tool_input"],
"tool_response": hook_inputs[0]["tool_response"],
}),
json!({
"hook_event_name": "PostToolUse",
"tool_name": DYNAMIC_HOOK_NAME,
"tool_use_id": call_id,
"tool_input": { "job": "post" },
"tool_response": [
{
"type": "input_text",
"text": "done",
},
{
"type": "input_image",
"image_url": "https://example.com/dynamic.png",
"detail": "high",
}
],
}),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn post_tool_use_does_not_fire_for_unsuccessful_dynamic_calls() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-dynamic-unsuccessful";
let arguments = json!({ "job": "fail" }).to_string();
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
DYNAMIC_NAMESPACE,
DYNAMIC_TOOL_NAME,
&arguments,
),
ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "dynamic failure observed"),
ev_completed("resp-2"),
]),
)
.await;
let test = build_dynamic_tool_test(
&server,
vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)],
move |home| {
if let Err(err) = write_post_tool_use_hook(
home,
DYNAMIC_HOOK_NAME,
Some("should not reach the model"),
) {
panic!("failed to write unsuccessful dynamic post hook: {err}");
}
},
)
.await?;
let turn_id = submit_dynamic_tool_turn(
&test,
"call the namespaced dynamic tool and fail it",
AskForApproval::Never,
)
.await?;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::DynamicToolCallRequest(request) => Some(request.clone()),
_ => None,
})
.await;
test.codex
.submit(Op::DynamicToolResponse {
id: request.call_id,
response: DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "tool failed".to_string(),
}],
success: false,
},
})
.await?;
wait_for_event(&test.codex, |event| match event {
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
_ => false,
})
.await;
let final_request = final_mock.single_request();
assert_eq!(
final_request.function_call_output(call_id)["output"],
json!("tool failed"),
);
assert!(
!final_request
.message_input_texts("developer")
.contains(&"should not reach the model".to_string()),
"unsuccessful dynamic tools should not inject PostToolUse context",
);
assert!(
read_hook_inputs(test.codex_home_path(), "post_tool_use_hook_log.jsonl")?.is_empty(),
"unsuccessful dynamic tools should not trigger PostToolUse hooks",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn post_tool_use_does_not_fire_for_canceled_dynamic_calls() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-dynamic-canceled";
let arguments = json!({ "job": "cancel" }).to_string();
let call_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
DYNAMIC_NAMESPACE,
DYNAMIC_TOOL_NAME,
&arguments,
),
ev_completed("resp-1"),
]),
)
.await;
let test = build_dynamic_tool_test(
&server,
vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)],
move |home| {
if let Err(err) = write_post_tool_use_hook(home, DYNAMIC_HOOK_NAME, Some("ignored")) {
panic!("failed to write canceled dynamic post hook: {err}");
}
},
)
.await?;
let turn_id = submit_dynamic_tool_turn(
&test,
"start the namespaced dynamic tool and then interrupt it",
AskForApproval::Never,
)
.await?;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::DynamicToolCallRequest(request) => Some(request.clone()),
_ => None,
})
.await;
assert_eq!(request.call_id, call_id);
test.codex.submit(Op::Interrupt).await?;
wait_for_event(&test.codex, |event| match event {
EventMsg::TurnAborted(event) => event.turn_id.as_deref() == Some(turn_id.as_str()),
_ => false,
})
.await;
call_mock.single_request();
assert!(
read_hook_inputs(test.codex_home_path(), "post_tool_use_hook_log.jsonl")?.is_empty(),
"canceled dynamic tools should not trigger PostToolUse hooks",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dynamic_tools_do_not_trigger_permission_request_hooks() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "permissionrequest-dynamic";
let arguments = json!({ "job": "approve-me" }).to_string();
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
DYNAMIC_NAMESPACE,
DYNAMIC_TOOL_NAME,
&arguments,
),
ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "dynamic tool completed without approval hooks"),
ev_completed("resp-2"),
]),
)
.await;
let test = build_dynamic_tool_test(
&server,
vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)],
move |home| {
if let Err(err) = write_permission_request_hook(home, DYNAMIC_HOOK_NAME) {
panic!("failed to write dynamic permission request hook: {err}");
}
},
)
.await?;
let turn_id = submit_dynamic_tool_turn(
&test,
"run the namespaced dynamic tool under unless-trusted approvals",
AskForApproval::UnlessTrusted,
)
.await?;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::DynamicToolCallRequest(request) => Some(request.clone()),
_ => None,
})
.await;
test.codex
.submit(Op::DynamicToolResponse {
id: request.call_id,
response: DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "still-ran".to_string(),
}],
success: true,
},
})
.await?;
wait_for_event(&test.codex, |event| match event {
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
_ => false,
})
.await;
assert_eq!(
final_mock.single_request().function_call_output(call_id)["output"],
json!("still-ran"),
);
assert!(
read_hook_inputs(test.codex_home_path(), "permission_request_hook_log.jsonl")?.is_empty(),
"dynamic tools should not start triggering PermissionRequest hooks in this pass",
);
Ok(())
}

View File

@@ -48,6 +48,8 @@ mod hierarchical_agents;
#[cfg(not(target_os = "windows"))]
mod hooks;
#[cfg(not(target_os = "windows"))]
mod hooks_dynamic;
#[cfg(not(target_os = "windows"))]
mod hooks_mcp;
mod image_rollout;
mod items;

View File

@@ -194,6 +194,14 @@ mod tests {
fn literal_matcher_uses_exact_matching() {
assert!(matches_matcher(Some("Bash"), Some("Bash")));
assert!(!matches_matcher(Some("Bash"), Some("BashOutput")));
assert!(matches_matcher(
Some("dynamic__codex_app__automation_update"),
Some("dynamic__codex_app__automation_update")
));
assert!(!matches_matcher(
Some("dynamic__codex_app"),
Some("dynamic__codex_app__automation_update")
));
assert!(matches_matcher(
Some("mcp__memory__create_entities"),
Some("mcp__memory__create_entities")
@@ -228,6 +236,23 @@ mod tests {
assert_eq!(validate_matcher_pattern("mcp__memory__.*"), Ok(()));
}
#[test]
fn dynamic_matchers_support_regex_wildcards() {
assert!(matches_matcher(
Some("dynamic__codex_app__.*"),
Some("dynamic__codex_app__automation_update")
));
assert!(matches_matcher(
Some("dynamic__.*__automation_update"),
Some("dynamic__codex_app__automation_update")
));
assert!(!matches_matcher(
Some("dynamic__other_app__.*"),
Some("dynamic__codex_app__automation_update")
));
assert_eq!(validate_matcher_pattern("dynamic__codex_app__.*"), Ok(()));
}
#[test]
fn matcher_supports_anchored_regexes() {
assert!(matches_matcher(Some("^Bash$"), Some("Bash")));