PostToolUse Hook
PostToolUse 处理的是“已经产生的成功工具结果”,不是工具事务的提交钩子。命令、补丁或 MCP 调用的副作用在 Hook 启动前 已经发生;同步 Command/MCP hook 可以拒绝结果回灌或用反馈替换模型可见输出,异步 Command 只能在后续 Turn 边界追加上下文 和完成事件。任何一种 PostToolUse 都不能回滚文件、进程或远端服务状态。
本文面向已经读过PreToolUse Hook、ToolOutput与错误模型和ToolLifecycle事件的读者。前文解释执行前阻断、工具输出类型和生命周期配对;本文只研究成功输出之后的 Hook,不重复 matcher 与命令执行器的一般规则。读完后,读者应能定位一个结果为什么触发或跳过 PostToolUse,解释长运行 unified exec 在哪个调用点触发,以及区分结果阻断、反馈替换、additional context 和 typed output 的不同消费者。
1. 触发资格
1.1 成功门槛
registry 先执行 handler,并从 ToolOutput::success_for_logging 取得成功标记。只有 Rust 调用返回 Ok 且该标记为 true,才会读取 post_tool_use_payload。因此“handler 返回了一个 output”并不足以触发 PostToolUse;例如 FunctionToolOutput { success: Some(false) } 会被跳过。
源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
let result = otel
.log_tool_result_with_tags(
&tool_name,
&call_id_owned,
log_payload.as_ref(),
&tool_result_tags,
&extra_trace_fields,
|| handle_any_tool(tool.as_ref(), invocation.clone()),
|result| {
(
result.result.log_output(),
result.result.success_for_logging(),
)
},
)
.await;
let success = match &result {
Ok(result) => result.result.success_for_logging(),
Err(_) => false,
};
let post_tool_use_payload = if success {
result
.as_ref()
.ok()
.and_then(|result| result.post_tool_use_payload.clone())
} else {
None
};这个门槛把工具错误与结果审查分开。执行失败、取消或 success_for_logging=false 的输出不运行 PostToolUse;它们仍沿原来的错误和生命周期路径返回。
1.2 Payload生成
post_tool_use_payload 在 handler 成功返回后立即由具体 CoreToolRuntime 构造,并存入 AnyToolResult。通用 function tool 默认使用原 arguments 作为 tool_input,优先读取 output 提供的稳定 post_tool_use_response;没有专用响应时,才把模型可见 function output body 转成 JSON。
源码位置:codex-rs/core/src/tools/registry.rs :: CoreToolRuntime::post_tool_use_payload
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
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(|| {
let ResponseInputItem::FunctionCallOutput {
output: FunctionCallOutputPayload { body, .. },
..
} = result.to_response_item(&invocation.call_id, &invocation.payload)
else {
return None;
};
serde_json::to_value(body).ok()
})?,
})
}源码位置:codex-rs/core/src/tools/registry.rs :: handle_any_tool
let output = tool.handle(invocation.clone()).await?;
let post_tool_use_payload =
CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref());
Ok(AnyToolResult {
call_id,
payload,
result: output,
post_tool_use_payload,
})2. 结果契约
2.1 Payload字段
PostToolUsePayload 比 PreToolUse 多两个关键字段:tool_use_id 用于把开始、完成和原工具调用关联起来;tool_response 是 handler 选择的稳定结果,而不是 registry 内部的 trait object。
源码位置:codex-rs/core/src/tools/registry.rs :: PostToolUsePayload
pub(crate) struct PostToolUsePayload {
pub(crate) tool_name: HookToolName,
pub(crate) tool_use_id: String,
pub(crate) tool_input: Value,
pub(crate) tool_response: Value,
}core 随后补入 session、turn、cwd、transcript、model、permission mode 和 subagent 信息,构造 PostToolUseRequest。canonical tool name 写入 stdin,matcher aliases 仍只用于选择配置。
源码位置:codex-rs/core/src/hook_runtime.rs :: run_post_tool_use_hooks
let request = PostToolUseRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
tool_name,
matcher_aliases,
tool_use_id,
tool_input,
tool_response,
};2.2 命令stdin
源码位置:codex-rs/hooks/src/events/post_tool_use.rs :: command_input_json
serde_json::to_string(&PostToolUseCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
transcript_path: crate::schema::NullableString::from_path(
request.transcript_path.clone(),
),
cwd: request.cwd.display().to_string(),
hook_event_name: "PostToolUse".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
tool_name: request.tool_name.clone(),
tool_input: request.tool_input.clone(),
tool_response: request.tool_response.clone(),
tool_use_id: request.tool_use_id.clone(),
})Hook consumer 应把 tool_input 和 tool_response 当作 handler 提供的公开契约。它们可能与模型请求和模型响应不同,例如 MCP output 保留 structured content,而 Unified Exec output 使用 runtime 完成时保存的原始命令与截断文本。Command hook 通过 stdin 接收这份 JSON,MCP hook 则用同一 event JSON 展开 input template。
3. 输出投影
3.1 命令结果
独立 shell handler 已删除。exec_command 与 write_stdin 都通过 post_unified_exec_tool_use_payload 投影 Bash 结果。 ExecCommandToolOutput 自己保存原始 event_call_id、hook_command、raw output 和截断策略;投影函数不需要从当前 invocation 的 write_stdin 参数反向推断原命令。
源码位置:codex-rs/core/src/tools/context.rs :: ExecCommandToolOutput
fn post_tool_use_id(&self, call_id: &str) -> String {
if self.event_call_id.is_empty() {
call_id.to_string()
} else {
self.event_call_id.clone()
}
}
fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
self.hook_command
.as_ref()
.map(|command| serde_json::json!({ "command": command }))
}
fn post_tool_use_response(
&self,
_call_id: &str,
_payload: &ToolPayload,
) -> Option<JsonValue> {
if self.process_id.is_some() || self.hook_command.is_none() {
return None;
}
Some(JsonValue::String(
self.truncated_output_with_policy(self.model_output_policy()),
))
}3.2 MCP结果
MCP 的 McpToolOutput 覆盖 input 与 response:input 是服务器实际消费的解析后参数,response 是完整 CallToolResult 的 JSON,包括 content、structured content、错误标记和 metadata 中可序列化的部分。它不使用带 wall-time header 的模型展示文本作为 Hook 响应。
源码位置:codex-rs/core/src/tools/context.rs :: McpToolOutput
fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
Some(self.tool_input.clone())
}
fn post_tool_use_response(
&self,
_call_id: &str,
_payload: &ToolPayload,
) -> Option<JsonValue> {
serde_json::to_value(&self.result).ok()
}源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: McpHandler::post_tool_use_payload
Some(PostToolUsePayload {
tool_name: self.hook_tool_name(),
tool_use_id: invocation.call_id.clone(),
tool_input: result.post_tool_use_input(&invocation.payload)?,
tool_response,
})3.3 补丁结果
apply_patch 保持与 PreToolUse 相同的 { "command": patch } 输入契约,response 则是 ApplyPatchToolOutput 的文本。它不把内部 AppliedPatchDelta 或 diff tracker 结构直接暴露给 Hook。
源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: ApplyPatchHandler::post_tool_use_payload
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::apply_patch(),
tool_use_id: invocation.call_id.clone(),
tool_input: serde_json::json!({
"command": apply_patch_payload_command(&invocation.payload)?,
}),
tool_response,
})3.4 投影比较
| 工具 | tool_input | tool_response | 未公开的内部状态 |
|---|---|---|---|
| exec_command / write_stdin完成 | 原始启动 command | 完成后的截断输出 | 活进程句柄、process store、raw bytes |
| apply_patch | { command: patch } | 完成文本 | AppliedPatchDelta、diff tracker |
| MCP | 解析后的 JSON 参数 | CallToolResult JSON | transport、server handle、wall-time header |
| 普通 function | function arguments | 专用 response 或 function output body | 具体 Rust output 类型 |
投影存在的目的不是少传字段,而是稳定 Hook 契约。内部 output 类型可以演进,只要 handler 维持 tool_input 与 tool_response 的语义,用户脚本就不必跟随每个 Rust 结构体变化。
4. 延迟完成
4.1 活进程跳过
unified exec 的第一次 exec_command 可能只返回一个仍在运行的 session。ExecCommandToolOutput::post_tool_use_response 在 process_id.is_some() 时返回 None,因此这次调用没有 PostToolUse payload。Hook 不能把“初次 yield”误判为命令已经完成。
源码位置:codex-rs/core/src/tools/context.rs :: ExecCommandToolOutput::post_tool_use_response
fn post_tool_use_response(
&self,
_call_id: &str,
_payload: &ToolPayload,
) -> Option<JsonValue> {
if self.process_id.is_some() || self.hook_command.is_none() {
return None;
}
Some(JsonValue::String(
self.truncated_output(self.model_output_max_tokens()),
))
}4.2 write_stdin接棒
当后续 write_stdin 观察到原命令结束时,它反而会生成 PostToolUse payload。post_tool_use_id 使用 output 保存的 event_call_id,tool_input 使用原始 hook_command,而不是本次 poll 的 session_id 和 chars。这让 PreToolUse 与 PostToolUse 仍围绕同一个 Bash 工具调用配对。
源码位置:codex-rs/core/src/tools/handlers/unified_exec.rs :: post_unified_exec_tool_use_payload
let tool_input = result.post_tool_use_input(&invocation.payload)?;
let tool_use_id = result.post_tool_use_id(&invocation.call_id);
let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id,
tool_input,
tool_response,
})源码位置:codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs :: WriteStdinHandler::post_tool_use_payload
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
post_unified_exec_tool_use_payload(invocation, result)
}4.3 并行隔离
每个 ExecCommandToolOutput 保存自己的 event_call_id 和 hook_command。测试同时构造两个已完成 session,并以相反顺序读取,断言 payload 仍分别关联到 exec-call-a/alpha 与 exec-call-b/beta。因此关联依据来自 output metadata,而不是当前 write_stdin 的调用顺序。
5. Hook执行
5.1 匹配与并发
PostToolUse 与 PreToolUse 复用同一 matcher、信任和 dispatcher。同步 Command 与 MCP Tool 获得同一 event JSON 并发运行; 异步 Command 被提交到后台 queue,不参与当前 outcome,也不能 block 或替换已经完成的工具结果。dispatcher 最终按配置顺序返回 同步结果,因此多条反馈的拼接顺序是配置顺序,不是实际完成顺序。
源码位置:codex-rs/hooks/src/events/post_tool_use.rs :: run
let matcher_inputs = common::matcher_inputs(
&request.tool_name,
&request.matcher_aliases,
);
let matched = dispatcher::select_handlers_for_matcher_inputs(
&engine.handlers,
HookEventName::PostToolUse,
&matcher_inputs,
);
let results = dispatcher::execute_handlers(
engine,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;5.2 Backend选择
dispatcher 根据 ConfiguredHandlerKind 选择外部 Command 或 MCP Tool。Command 接收 JSON stdin;MCP Tool 先展开配置的 input template,再调用 HookMcpExecutor。两者最终都生成 HandlerRunResult,所以 PostToolUse 的 block、stop、feedback 和 context parser 不需要区分 backend。
源码位置:codex-rs/hooks/src/engine/dispatcher.rs :: execute_handler
match &handler.kind {
ConfiguredHandlerKind::Command { command, env, .. } => {
run_command(
&engine.command_runtime,
handler,
command,
env,
input_json,
cwd,
)
.await
}
ConfiguredHandlerKind::McpTool { server, tool, input } => {
run_mcp_tool(
engine.mcp_executor.as_ref(),
handler,
server,
tool,
input,
input_json,
metadata,
)
.await
}
}5.3 聚合结果
additional contexts 先按 handler 配置顺序展开,再分别应用每个 handler 的输出限制。should_block 只要任一 handler 阻断就为 true;所有反馈文本使用空行连接成一个 feedback_message。
源码位置:codex-rs/hooks/src/events/post_tool_use.rs :: run
let additional_contexts = common::flatten_additional_contexts(
results
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
let additional_contexts = engine
.command_runtime
.output_spiller()
.maybe_spill_additional_contexts(additional_contexts)
.await;
let should_block = results.iter().any(|result| result.data.should_block);
let feedback_message = common::join_text_chunks(
results
.iter()
.flat_map(|result| result.data.feedback_messages_for_model.clone())
.collect(),
);additional context 仍经过每个 handler 的预算和 output spiller。同步 feedback 按配置顺序用空行连接;当前 PostToolUse outcome 直接返回合并文本,不再由 engine 做第二次 combined feedback spill。
6. 输出协议
6.1 Block决策
PostToolUse 当前只允许顶层 decision: "block" 表达结果阻断,并要求非空 reason。Hook-specific 的 additionalContext 可以同时存在,但 updatedMCPToolOutput 虽然保留在 schema 中,当前 parser 明确拒绝。上述控制字段只对 handler.can_apply_control_effects() 为 true 的同步 Command/MCP 生效;异步 command 的相同输出不会拒绝当前结果。
源码位置:codex-rs/hooks/src/engine/output_parser.rs :: parse_post_tool_use
let universal = UniversalOutput::from(wire.universal);
let invalid_reason = unsupported_post_tool_use_universal(&universal).or_else(|| {
wire.hook_specific_output
.as_ref()
.and_then(unsupported_post_tool_use_hook_specific_output)
});
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
Some(reason) => reason.trim().is_empty(),
None => true,
}
{
Some(invalid_block_message("PostToolUse"))
} else if !should_block && universal.continue_processing && wire.reason.is_some() {
Some("PostToolUse hook returned reason without decision".to_string())
} else {
None
};decision:block 是“不要把原结果作为正常工具结果继续交给模型”,不是“撤销工具”。registry 最终返回 FunctionCallError::RespondToModel,但文件修改、命令输出或远端调用已经发生。
6.2 Stop语义
continue:false 走的是另一条路径。parse_completed 将单个 Hook 状态记为 Stopped,从 reason、stopReason 或默认文本构造 feedback;它不会设置 should_block=true。registry 因此不会返回错误,而是用反馈替换模型可见的工具输出。
源码位置:codex-rs/hooks/src/events/post_tool_use.rs :: parse_completed
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
let stop_text = parsed
.universal
.stop_reason
.unwrap_or_else(|| "PostToolUse hook stopped execution".to_string());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: stop_text.clone(),
});
let model_feedback = parsed
.reason
.as_deref()
.and_then(common::trimmed_non_empty)
.unwrap_or(stop_text);
feedback_messages_for_model.push(model_feedback);
} else if parsed.should_block {
status = HookRunStatus::Blocked;
should_block = true;
}这一区分非常重要:Blocked 让当前工具结果以错误结束,Stopped 只改变模型下一步看到的文本。在协议事件中二者也对应不同 HookRunStatus。
6.3 退出码
| Hook 结果 | 状态 | should_block | 模型侧结果 |
|---|---|---|---|
| 退出 0,空或普通文本 | Completed | false | 保留原结果 |
同步退出 0,合法 decision:block | Blocked | true | 返回 feedback 错误 |
同步退出 0,continue:false | Stopped | false | feedback 替换原结果 |
| 异步输出 block/stop | 后台完成 | false | 当前结果不变 |
| 退出 0,仅 additional context | Completed | false | 原结果不变,记录上下文 |
| 退出 0,非法 JSON 或不支持字段 | Failed | false | 保留原结果 |
| 退出 2,stderr 非空 | Blocked | true | stderr 作为 feedback 错误 |
| 退出 2,stderr 为空 | Failed | false | 保留原结果 |
| spawn、stdin、wait、timeout 失败 | Failed | false | 保留原结果 |
7. 结果消费
7.1 Block结果
registry 在 Hook 完成后先记录 additional contexts,再计算原工具的 lifecycle outcome。随后如果 outcome.should_block 为 true,才返回模型可见错误。这里不会取出并回传原 AnyToolResult。
源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
if let Some(outcome) = &post_tool_use_outcome {
record_additional_contexts(
&invocation.session,
&invocation.turn,
outcome.additional_contexts.clone(),
)
.await;
}
notify_tool_finish_if_unclaimed(
&invocation,
terminal_outcome_reached.as_deref(),
lifecycle_outcome,
)
.await;
if let Some(outcome) = post_tool_use_outcome {
if outcome.should_block {
let message = outcome.feedback_message.unwrap_or_else(|| {
"PostToolUse hook blocked the tool result".to_string()
});
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
}因此同一次调用可能出现两种不同观察:工具生命周期贡献者收到 Completed { success: true },因为 handler 已成功完成;dispatch trace 则记录失败,因为结果被 PostToolUse 拒绝回灌。它们描述的是不同阶段,不是数据冲突。
7.2 Feedback包装
非阻断 feedback 使用 PostToolUseFeedbackOutput 包装原 output。to_response_item 返回 feedback 文本,因此普通模型对话看到的是 Hook 反馈;code_mode_result 仍委托给原 output,代码执行环境继续获得原来的结构化值。日志预览和成功标记也保留原 output。
源码位置:codex-rs/core/src/tools/registry.rs :: PostToolUseFeedbackOutput
impl ToolOutput for PostToolUseFeedbackOutput {
fn log_output(&self) -> String {
self.original.log_output()
}
fn success_for_logging(&self) -> bool {
self.original.success_for_logging()
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
self.model_visible.to_response_item(call_id, payload)
}
fn code_mode_result(&self, payload: &ToolPayload) -> Value {
self.original.code_mode_result(payload)
}
}源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
if let Some(feedback_message) = outcome.feedback_message {
result.result = Box::new(PostToolUseFeedbackOutput {
original: result.result,
model_visible: FunctionToolOutput::from_text(
feedback_message,
/*success*/ None,
),
});
}7.3 接受回调
Hook block 返回之前不会调用 on_tool_result_accepted。只有结果未被阻断,并且 feedback wrapper 已经完成后,registry 才通知 具体 runtime“该结果已被接受”。当前 MCP Code Mode 使用这个回调捕获审阅材料和图片;被 PostToolUse 拒绝的 MCP 结果不会进入该证据消费者。
源码位置:codex-rs/core/src/tools/registry.rs :: CoreToolRuntime::on_tool_result_accepted
if let Some(outcome) = post_tool_use_outcome {
if outcome.should_block {
return Err(FunctionCallError::RespondToModel(message));
}
if let Some(feedback_message) = outcome.feedback_message {
result.result = Box::new(PostToolUseFeedbackOutput {
original: result.result,
model_visible: FunctionToolOutput::from_text(
feedback_message,
None,
),
});
}
}
tool.on_tool_result_accepted(&invocation, result.result.as_ref());7.4 上下文回流
同步 additional context 不替换当前输出,而是通过 record_additional_contexts 追加为 conversation items。即使结果随后被 block,这些上下文也已经记录。异步 Command 的 context 则通过 Session result channel,在下一个安全 Turn 边界写入 history 或 pending input;它不会延迟当前结果交付。feedback 和 context 的消费者不同:feedback 处理当前结果,context 影响后续模型 请求。
8. 生命周期边界
8.1 副作用时机
PostToolUse 启动时 tool.handle 已经返回,Unified Exec、Apply Patch 或 MCP adapter 已完成自身结果构造。Hook 无法恢复删除的 文件、终止已经退出的进程或撤销远端调用。需要防止动作发生时,应使用 PreToolUse 或 PermissionRequest,而不是依赖 PostToolUse block。
8.2 生命周期顺序
registry 只等待同步 PostToolUse 完成并记录同步 context,然后发送 tool finish lifecycle;lifecycle outcome 只读取原 handler 结果。随后才处理 Hook block、feedback wrapper 和 accepted callback。异步 Command 已经进入后台 queue,不阻塞 tool finish, 其 completion event 稍后由 Turn drain 发出。
8.3 失败工具
如果 handler 返回 Err,registry 没有 AnyToolResult,自然不能构造 tool_response。如果 handler 返回 Ok 但 success_for_logging=false,payload 也不会进入 Hook。这是当前版本的明确边界:没有单独的 PostToolUseFailure 事件,失败工具的诊断应沿原工具错误、生命周期和日志读取。
9. 异常路径
9.1 Hook运行失败
Command 的 spawn/stdin/wait/timeout、MCP template 或调用失败、非 0/2 退出码和非法 JSON 都把同步 handler 标记为 Failed, 但默认保留原工具结果。异步 Command 的失败稍后形成 completion event,不改变当前结果。PostToolUse 的失败开放很重要,因为 工具已经完成,此时因观察器故障丢弃结果会扩大故障范围。
9.2 不支持字段
schema 保留 updatedMCPToolOutput,但 unsupported_post_tool_use_hook_specific_output 当前返回错误。测试断言它产生 Failed 状态、无 feedback、无 block。文章或脚本不能据此声称 PostToolUse 已经能够修改 MCP 原始返回值。
源码位置:codex-rs/hooks/src/engine/output_parser.rs :: unsupported_post_tool_use_hook_specific_output
fn unsupported_post_tool_use_hook_specific_output(
output: &PostToolUseHookSpecificOutputWire,
) -> Option<String> {
if output.updated_mcp_tool_output.is_some() {
Some("PostToolUse hook returned unsupported updatedMCPToolOutput".to_string())
} else {
None
}
}9.3 序列化失败
如果 PostToolUseCommandInput 无法序列化,引擎为所有已匹配 handler 生成 Failed completion events,但返回 should_block=false、空 context 和空 feedback。原工具结果继续返回,Hook 的失败仍可通过事件观察。
10. 源码验证
10.1 裁决测试
源码位置:codex-rs/hooks/src/events/post_tool_use.rs :: block_decision_stops_normal_processing
测试输入退出码 0、decision:block 和非空 reason,断言 should_block=true、feedback 包含原因、状态为 Blocked。它证明结果阻断的 parser 语义,不证明工具副作用被撤销。
源码位置:codex-rs/hooks/src/events/post_tool_use.rs :: continue_false_stops_with_reason
测试输入 continue:false、stopReason 和 reason,断言 Hook 状态为 Stopped,但 should_block=false,模型反馈优先使用 reason。它验证 Stop 与 Block 是两条不同结果路径。
源码位置:codex-rs/hooks/src/events/post_tool_use.rs :: unsupported_updated_mcp_tool_output_fails_open
测试输入 updatedMCPToolOutput,断言状态为 Failed,没有阻断、上下文或 feedback。它限定了当前 PostToolUse 只能反馈和追加 context,不能改写 MCP 结果。
10.2 投影测试
源码位置:codex-rs/core/src/tools/handlers/unified_exec_tests.rs :: exec_command_post_tool_use_payload_skips_running_sessions
测试构造带 process_id 的 unified exec output,断言 handler 返回 None。相邻的 completion 测试再断言 process 结束后能够生成 Bash payload,证明触发点跟随命令完成而不是初次 exec 调用。
源码位置:codex-rs/core/src/tools/handlers/unified_exec_tests.rs :: write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_command_on_completion
测试通过 write_stdin invocation 传入已完成 output,断言 Hook 使用原 exec call id 和原 command,而不是 write_stdin call id。它证明长进程前后事件可以正确配对。
源码位置:codex-rs/core/src/tools/registry_tests.rs :: post_tool_use_feedback_output_keeps_code_mode_result_typed
测试对同一个 wrapper 分别调用 into_response 和 code_mode_result:普通响应得到 Hook feedback,Code Mode 得到原 JSON { "typed": true }。它证明反馈替换不会破坏内部结构化消费者。
源码位置:codex-rs/hooks/src/engine/mcp_runner_tests.rs :: mcp_tool_results_use_command_hook_output_contract
测试让 MCP hook 返回 PostToolUse JSON 文本,断言它被放入统一 HandlerRunResult.stdout 并由事件 parser 消费。它证明 MCP backend 与 Command backend 共享输出协议,不证明外部 MCP server 的业务正确性。
源码位置:codex-rs/hooks/src/engine/command_runner_tests.rs :: async_hook_marks_invalid_structured_output_as_failed
测试等待异步 result channel,断言非法结构化输出形成 Failed completion。由于该 handler 已脱离当前 dispatch,它不会撤销或 替换原工具结果。
10.3 可执行检查
rg -n "post_tool_use_payload|run_post_tool_use_hooks|PostToolUseFeedbackOutput" codex-rs/core/src/tools codex-rs/core/src/hook_runtime.rs
rg -n "parse_post_tool_use|continue_false|updatedMCPToolOutput" codex-rs/hooks/src
cargo test -p codex-hooks events::post_tool_use::tests::block_decision_stops_normal_processing
cargo test -p codex-hooks engine::mcp_runner::tests::mcp_tool_results_use_command_hook_output_contract
cargo test -p codex-hooks engine::command_runner::tests::async_hook_marks_invalid_structured_output_as_failed
cargo test -p codex-core tools::handlers::unified_exec::tests::exec_command_post_tool_use_payload_skips_running_sessions
cargo test -p codex-core tools::handlers::unified_exec::tests::write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_command_on_completion
cargo test -p codex-core tools::registry::tests::post_tool_use_feedback_output_keeps_code_mode_result_typed这些命令验证 parser、MCP/async backend、长进程完成关联和 typed output wrapper;它们不会验证某个外部 Hook 或 MCP server 的 业务判断,也不会证明 PostToolUse 具备事务回滚能力。
11. 诊断路径
遇到“PostToolUse 没触发”,先检查原 output 的 success_for_logging,再检查 handler 是否能生成 post_tool_use_response;unified exec 还要确认 process_id 是否仍存在。遇到“工具明明执行成功却返回错误”,检查 Hook 是否产生 block 或退出码 2,并同时查看 lifecycle 与 dispatch trace,因为二者记录的是工具完成和结果交付两个不同阶段。遇到“模型看到 Hook 文本但 Code Mode 仍拿到原 JSON”,这是 PostToolUseFeedbackOutput 的既定双消费者设计,不是缓存不一致。
