Skip to content

PostToolUse Hook

追踪成功工具结果如何投影为 PostToolUse 输入,以及 Hook 如何阻断结果回灌、追加上下文或替换模型可见输出。

基于rust-v0.150.0
CodexRustToolsHooks

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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_inputtool_response未公开的内部状态
exec_command / write_stdin完成原始启动 command完成后的截断输出活进程句柄、process store、raw bytes
apply_patch{ command: patch }完成文本AppliedPatchDelta、diff tracker
MCP解析后的 JSON 参数CallToolResult JSONtransport、server handle、wall-time header
普通 functionfunction 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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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,空或普通文本Completedfalse保留原结果
同步退出 0,合法 decision:blockBlockedtrue返回 feedback 错误
同步退出 0,continue:falseStoppedfalsefeedback 替换原结果
异步输出 block/stop后台完成false当前结果不变
退出 0,仅 additional contextCompletedfalse原结果不变,记录上下文
退出 0,非法 JSON 或不支持字段Failedfalse保留原结果
退出 2,stderr 非空Blockedtruestderr 作为 feedback 错误
退出 2,stderr 为空Failedfalse保留原结果
spawn、stdin、wait、timeout 失败Failedfalse保留原结果

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

rust
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

rust
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

rust
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

rust
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

rust
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 可执行检查 ​

bash
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 的既定双消费者设计,不是缓存不一致。