hooks: emit Bash PostToolUse when exec_command completes via write_stdin (#18888)

Fixes #16246.

## Why

`exec_command` already emits `PreToolUse`, but long-running unified exec
commands that finish on a later `write_stdin` poll could miss the
matching `PostToolUse`. That left the Bash hook lifecycle inconsistent,
broke expectations around `tool_use_id` and `tool_input.command`, and
meant `PostToolUse` block/replacement feedback could fail to replace the
final session output before it reached model context.

This keeps the fix scoped to the `exec_command` / `write_stdin`
lifecycle. Broader non-Bash hook expansion is still out of scope here
and remains tracked separately in #16732.

## What changed

- Compute and store `PostToolUsePayload` while handlers still have
access to their concrete output type, and carry `tool_use_id` through
that payload.
- Preserve the original hook-facing `exec_command` string through
unified exec state (`ExecCommandRequest`, `ProcessEntry`,
`PreparedProcessHandles`, and `ExecCommandToolOutput`) via
`hook_command`, and remove the now-unused `session_command` output
metadata.
- Emit exactly one Bash `PostToolUse` for long-running `exec_command`
sessions when a later `write_stdin` poll observes final completion,
using the original `exec_command` call id and hook-facing command.
- Keep one-shot `exec_command` behavior aligned with the same payload
construction, including interactive completions that return a final
result directly.
- Apply `PostToolUse` block/replacement feedback before the final
`write_stdin` completion output is sent back to the model.
- Keep `write_stdin` itself out of `PreToolUse` matching so it continues
to act as transport/polling for the original Bash tool call.
- Restore plain matcher behavior for tool-name matchers such as `Bash`
and `Edit|Write`, while still treating patterns with regex characters
(for example `mcp__.*`) as regexes.
- Add unit coverage for unified exec payload construction and parallel
session separation, plus a core integration regression that verifies a
blocked `PostToolUse` replaces the final `write_stdin` output in model
context.

## Testing

- `cargo test -p codex-hooks`
- `cargo test -p codex-core post_tool_use_payload`
- `cargo test -p codex-core
post_tool_use_blocks_when_exec_session_completes_via_write_stdin`
This commit is contained in:
Andrei Eternal
2026-04-22 17:14:22 -07:00
committed by GitHub
parent 6ca038bbd1
commit eed0e07825
15 changed files with 345 additions and 78 deletions

View File

@@ -370,7 +370,7 @@ pub struct ExecCommandToolOutput {
pub process_id: Option<i32>,
pub exit_code: Option<i32>,
pub original_token_count: Option<usize>,
pub session_command: Option<Vec<String>>,
pub hook_command: Option<String>,
}
impl ToolOutput for ExecCommandToolOutput {
@@ -394,7 +394,7 @@ impl ToolOutput for ExecCommandToolOutput {
}
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
if self.process_id.is_some() || self.session_command.is_none() {
if self.process_id.is_some() || self.hook_command.is_none() {
return None;
}

View File

@@ -390,7 +390,7 @@ fn exec_command_tool_output_formats_truncated_response() {
process_id: None,
exit_code: Some(0),
original_token_count: Some(10),
session_command: None,
hook_command: None,
}
.to_response_item("call-42", &payload);

View File

@@ -324,11 +324,12 @@ impl ToolHandler for ApplyPatchHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::apply_patch(),
tool_use_id: call_id.to_string(),
command: apply_patch_payload_command(payload)?,
tool_response,
})

View File

@@ -90,6 +90,7 @@ fn post_tool_use_payload_uses_patch_input_and_tool_output() {
handler.post_tool_use_payload("call-apply-patch", &payload, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::apply_patch(),
tool_use_id: "call-apply-patch".to_string(),
command: patch.to_string(),
tool_response: json!("Success. Updated files."),
})

View File

@@ -216,11 +216,12 @@ impl ToolHandler for ShellHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: call_id.to_string(),
command: shell_payload_command(payload)?,
tool_response,
})
@@ -328,11 +329,12 @@ impl ToolHandler for ShellCommandHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: call_id.to_string(),
command: shell_command_payload_command(payload)?,
tool_response,
})

View File

@@ -284,6 +284,7 @@ fn build_post_tool_use_payload_uses_tool_output_wire_value() {
handler.post_tool_use_payload("call-42", &payload, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-42".to_string(),
command: "printf shell command".to_string(),
tool_response: json!("shell output"),
})

View File

@@ -147,21 +147,23 @@ impl ToolHandler for UnifiedExecHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { arguments } = payload else {
let ToolPayload::Function { .. } = payload else {
return None;
};
let args = parse_arguments::<ExecCommandArgs>(arguments).ok()?;
if args.tty {
return None;
}
let tool_response = result.post_tool_use_response(call_id, payload)?;
let command = result.hook_command.clone()?;
let tool_use_id = if result.event_call_id.is_empty() {
call_id.to_string()
} else {
result.event_call_id.clone()
};
let tool_response = result.post_tool_use_response(&tool_use_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
command: args.cmd,
tool_use_id,
command,
tool_response,
})
}
@@ -200,11 +202,12 @@ impl ToolHandler for UnifiedExecHandler {
"exec_command" => {
let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?;
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
let hook_command = args.cmd.clone();
let workdir = context.turn.resolve_path(args.workdir.clone());
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&args.cmd,
&hook_command,
&workdir,
)
.await;
@@ -313,17 +316,16 @@ impl ToolHandler for UnifiedExecHandler {
process_id: None,
exit_code: None,
original_token_count: None,
session_command: None,
hook_command: None,
});
}
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
let session_command = command.clone();
match manager
.exec_command(
ExecCommandRequest {
command,
hook_command: args.cmd,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
max_output_tokens,
@@ -357,7 +359,7 @@ impl ToolHandler for UnifiedExecHandler {
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
session_command: Some(session_command),
hook_command: Some(hook_command),
}
}
Err(err) => {

View File

@@ -252,7 +252,7 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co
arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(),
};
let output = ExecCommandToolOutput {
event_call_id: "event-43".to_string(),
event_call_id: "call-43".to_string(),
chunk_id: "chunk-1".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"three".to_vec(),
@@ -260,17 +260,14 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co
process_id: None,
exit_code: Some(0),
original_token_count: None,
session_command: Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"echo three".to_string(),
]),
hook_command: Some("echo three".to_string()),
};
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("call-43", &payload, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-43".to_string(),
command: "echo three".to_string(),
tool_response: serde_json::json!("three"),
})
@@ -278,12 +275,12 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co
}
#[test]
fn exec_command_post_tool_use_payload_skips_interactive_exec() {
fn exec_command_post_tool_use_payload_uses_output_for_interactive_completion() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "cmd": "echo three", "tty": true }).to_string(),
};
let output = ExecCommandToolOutput {
event_call_id: "event-44".to_string(),
event_call_id: "call-44".to_string(),
chunk_id: "chunk-1".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"three".to_vec(),
@@ -291,16 +288,17 @@ fn exec_command_post_tool_use_payload_skips_interactive_exec() {
process_id: None,
exit_code: Some(0),
original_token_count: None,
session_command: Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"echo three".to_string(),
]),
hook_command: Some("echo three".to_string()),
};
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("call-44", &payload, &output),
None
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-44".to_string(),
command: "echo three".to_string(),
tool_response: serde_json::json!("three"),
})
);
}
@@ -318,11 +316,7 @@ fn exec_command_post_tool_use_payload_skips_running_sessions() {
process_id: Some(45),
exit_code: None,
original_token_count: None,
session_command: Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"echo three".to_string(),
]),
hook_command: Some("echo three".to_string()),
};
assert_eq!(
@@ -330,3 +324,87 @@ fn exec_command_post_tool_use_payload_skips_running_sessions() {
None
);
}
#[test]
fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_command_on_completion() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({
"session_id": 45,
"chars": "",
})
.to_string(),
};
let output = ExecCommandToolOutput {
event_call_id: "exec-call-45".to_string(),
chunk_id: "chunk-2".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"finished\n".to_vec(),
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: None,
hook_command: Some("sleep 1; echo finished".to_string()),
};
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("write-stdin-call", &payload, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-45".to_string(),
command: "sleep 1; echo finished".to_string(),
tool_response: serde_json::json!("finished\n"),
})
);
}
#[test]
fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separate() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "session_id": 45, "chars": "" }).to_string(),
};
let output_a = ExecCommandToolOutput {
event_call_id: "exec-call-a".to_string(),
chunk_id: "chunk-a".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"alpha\n".to_vec(),
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: None,
hook_command: Some("sleep 2; echo alpha".to_string()),
};
let output_b = ExecCommandToolOutput {
event_call_id: "exec-call-b".to_string(),
chunk_id: "chunk-b".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"beta\n".to_vec(),
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: None,
hook_command: Some("sleep 1; echo beta".to_string()),
};
let payloads = [
UnifiedExecHandler.post_tool_use_payload("write-call-b", &payload, &output_b),
UnifiedExecHandler.post_tool_use_payload("write-call-a", &payload, &output_a),
];
assert_eq!(
payloads,
[
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-b".to_string(),
command: "sleep 1; echo beta".to_string(),
tool_response: serde_json::json!("beta\n"),
}),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-a".to_string(),
command: "sleep 2; echo alpha".to_string(),
tool_response: serde_json::json!("alpha\n"),
}),
]
);
}

View File

@@ -178,6 +178,7 @@ impl ToolCallRuntime {
result: Box::new(AbortedToolOutput {
message: Self::abort_message(call, secs),
}),
post_tool_use_payload: None,
}
}

View File

@@ -72,7 +72,7 @@ pub trait ToolHandler: Send + Sync {
&self,
_call_id: &str,
_payload: &ToolPayload,
_result: &dyn ToolOutput,
_result: &Self::Output,
) -> Option<PostToolUsePayload> {
None
}
@@ -107,6 +107,7 @@ pub(crate) struct AnyToolResult {
pub(crate) call_id: String,
pub(crate) payload: ToolPayload,
pub(crate) result: Box<dyn ToolOutput>,
pub(crate) post_tool_use_payload: Option<PostToolUsePayload>,
}
impl AnyToolResult {
@@ -143,9 +144,11 @@ pub(crate) struct PreToolUsePayload {
pub(crate) struct PostToolUsePayload {
/// Hook-facing tool name model.
///
/// Keep this aligned with the corresponding pre-use payload so external
/// hook consumers can pair events by `tool_use_id`.
/// The canonical name is serialized to hook stdin, while aliases are used
/// only for matcher compatibility.
pub(crate) tool_name: HookToolName,
/// The originating tool-use id exposed at `tool_use_id`.
pub(crate) tool_use_id: String,
/// Command-shaped input exposed at `tool_input.command`.
pub(crate) command: String,
/// Tool result exposed at `tool_response`.
@@ -159,15 +162,7 @@ trait AnyToolHandler: Send + Sync {
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload>;
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload>;
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>>;
fn handle_any<'a>(
&'a self,
invocation: ToolInvocation,
@@ -190,19 +185,9 @@ where
ToolHandler::pre_tool_use_payload(self, invocation)
}
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
ToolHandler::post_tool_use_payload(self, call_id, payload, result)
}
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
ToolHandler::create_diff_consumer(self)
}
fn handle_any<'a>(
&'a self,
invocation: ToolInvocation,
@@ -211,10 +196,13 @@ where
let call_id = invocation.call_id.clone();
let payload = invocation.payload.clone();
let output = self.handle(invocation).await?;
let post_tool_use_payload =
ToolHandler::post_tool_use_payload(self, &call_id, &payload, &output);
Ok(AnyToolResult {
call_id,
payload,
result: Box::new(output),
post_tool_use_payload,
})
})
}
@@ -400,13 +388,9 @@ impl ToolRegistry {
emit_metric_for_tool_read(&invocation, success).await;
let post_tool_use_payload = if success {
let guard = response_cell.lock().await;
guard.as_ref().and_then(|result| {
handler.post_tool_use_payload(
&result.call_id,
&result.payload,
result.result.as_ref(),
)
})
guard
.as_ref()
.and_then(|result| result.post_tool_use_payload.clone())
} else {
None
};
@@ -415,7 +399,7 @@ impl ToolRegistry {
run_post_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
post_tool_use_payload.tool_use_id,
post_tool_use_payload.tool_name.name().to_string(),
post_tool_use_payload.tool_name.matcher_aliases().to_vec(),
post_tool_use_payload.command,

View File

@@ -148,7 +148,7 @@ struct ProcessEntry {
process: Arc<UnifiedExecProcess>,
call_id: String,
process_id: i32,
command: Vec<String>,
hook_command: String,
tty: bool,
network_approval_id: Option<String>,
session: Weak<Session>,

View File

@@ -113,7 +113,7 @@ async fn exec_command_with_tty(
process: Arc::clone(&process),
call_id: context.call_id.clone(),
process_id,
command: command.clone(),
hook_command: cmd.to_string(),
tty,
network_approval_id: None,
session: Arc::downgrade(session),
@@ -165,7 +165,7 @@ async fn exec_command_with_tty(
process_id: response_process_id,
exit_code,
original_token_count: Some(approx_token_count(&text)),
session_command: Some(command),
hook_command: Some(cmd.to_string()),
})
}

View File

@@ -169,7 +169,7 @@ struct PreparedProcessHandles {
output_closed_notify: Arc<Notify>,
cancellation_token: CancellationToken,
pause_state: Option<watch::Receiver<bool>>,
command: Vec<String>,
hook_command: String,
process_id: i32,
tty: bool,
}
@@ -279,6 +279,7 @@ impl UnifiedExecProcessManager {
Arc::clone(&process),
context,
&request.command,
request.hook_command.clone(),
cwd.clone(),
start,
request.process_id,
@@ -398,7 +399,7 @@ impl UnifiedExecProcessManager {
process_id: response_process_id,
exit_code,
original_token_count: Some(original_token_count),
session_command: Some(request.command.clone()),
hook_command: Some(request.hook_command.clone()),
};
Ok(response)
@@ -418,7 +419,7 @@ impl UnifiedExecProcessManager {
output_closed_notify,
cancellation_token,
pause_state,
command: session_command,
hook_command,
process_id,
tty,
..
@@ -517,7 +518,7 @@ impl UnifiedExecProcessManager {
process_id,
exit_code,
original_token_count: Some(original_token_count),
session_command: Some(session_command.clone()),
hook_command: Some(hook_command),
};
Ok(response)
@@ -585,7 +586,7 @@ impl UnifiedExecProcessManager {
output_closed_notify,
cancellation_token,
pause_state,
command: entry.command.clone(),
hook_command: entry.hook_command.clone(),
process_id: entry.process_id,
tty: entry.tty,
})
@@ -597,6 +598,7 @@ impl UnifiedExecProcessManager {
process: Arc<UnifiedExecProcess>,
context: &UnifiedExecContext,
command: &[String],
hook_command: String,
cwd: AbsolutePathBuf,
started_at: Instant,
process_id: i32,
@@ -608,7 +610,7 @@ impl UnifiedExecProcessManager {
process: Arc::clone(&process),
call_id: context.call_id.clone(),
process_id,
command: command.to_vec(),
hook_command,
tty,
network_approval_id,
session: Arc::downgrade(&context.session),