Compare commits

...

8 Commits

Author SHA1 Message Date
Abhinav Vedmala
0ea83813ef Merge main into post-tool output rewrite 2026-05-13 00:15:54 -07:00
Abhinav Vedmala
5fb75cf27f Document code-mode rewrite behavior 2026-05-06 16:13:47 -07:00
Abhinav Vedmala
671a09536b rewrite post-tool outputs for code mode 2026-05-06 16:08:27 -07:00
Abhinav Vedmala
76cd705327 keep post-tool rewrites inside tool outputs 2026-05-05 11:50:28 -07:00
Abhinav Vedmala
b06ef69f90 simplify post-tool output rewrite flow 2026-05-05 10:02:14 -07:00
Abhinav Vedmala
365bf45526 preserve typed outputs for post-tool rewrites 2026-05-01 17:06:26 -07:00
Abhinav Vedmala
a041fd668b address PostToolUse review feedback 2026-05-01 16:33:11 -07:00
Abhinav Vedmala
fa92ad8b0e support PostToolUse output rewrites 2026-05-01 16:24:07 -07:00
10 changed files with 691 additions and 31 deletions

View File

@@ -96,6 +96,16 @@ pub trait ToolOutput: Send {
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
response_input_to_code_mode_result(self.to_response_item("", payload))
}
/// Returns the caller-visible value that code mode should receive after a
/// `PostToolUse` hook accepts `updatedToolOutput`.
fn rewritten_code_mode_result(
&self,
_payload: &ToolPayload,
updated_tool_output: &JsonValue,
) -> JsonValue {
updated_tool_output.clone()
}
}
impl ToolOutput for CallToolResult {
@@ -289,6 +299,51 @@ impl ToolOutput for FunctionToolOutput {
}
}
/// Preserves the original typed tool output while rewriting what model-authored
/// callers see.
pub(crate) struct CallerVisibleRewriteOutput {
original_tool_output: Box<dyn ToolOutput>,
updated_tool_output: JsonValue,
}
impl CallerVisibleRewriteOutput {
pub(crate) fn new(
original_tool_output: Box<dyn ToolOutput>,
updated_tool_output: JsonValue,
) -> Self {
Self {
original_tool_output,
updated_tool_output,
}
}
}
impl ToolOutput for CallerVisibleRewriteOutput {
fn log_preview(&self) -> String {
self.original_tool_output.log_preview()
}
fn success_for_logging(&self) -> bool {
self.original_tool_output.success_for_logging()
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
FunctionToolOutput::from_text(
match &self.updated_tool_output {
JsonValue::String(text) => text.clone(),
_ => self.updated_tool_output.to_string(),
},
Some(true),
)
.to_response_item(call_id, payload)
}
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
self.original_tool_output
.rewritten_code_mode_result(payload, &self.updated_tool_output)
}
}
pub struct ApplyPatchToolOutput {
pub text: String,
}
@@ -324,6 +379,9 @@ impl ToolOutput for ApplyPatchToolOutput {
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
// Native code-mode callers only need the side effect here. If a
// `PostToolUse` hook installs a caller-visible rewrite, the wrapper
// returns that replacement instead of this empty native result.
JsonValue::Object(serde_json::Map::new())
}
}
@@ -431,6 +489,21 @@ impl ToolOutput for ExecCommandToolOutput {
JsonValue::String(format!("failed to serialize exec result: {err}"))
})
}
// Code mode consumes exec results as a structured envelope, so preserve
// metadata such as exit code and timing while replacing only the
// caller-visible command output.
fn rewritten_code_mode_result(
&self,
payload: &ToolPayload,
updated_tool_output: &JsonValue,
) -> JsonValue {
let mut result = self.code_mode_result(payload);
if let JsonValue::Object(fields) = &mut result {
fields.insert("output".to_string(), updated_tool_output.clone());
}
result
}
}
impl ExecCommandToolOutput {

View File

@@ -12,6 +12,7 @@ use crate::memory_usage::emit_metric_for_tool_read;
use crate::sandbox_tags::permission_profile_policy_tag;
use crate::sandbox_tags::permission_profile_sandbox_tag;
use crate::session::turn_context::TurnContext;
use crate::tools::context::CallerVisibleRewriteOutput;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
@@ -515,6 +516,15 @@ impl ToolRegistry {
/*success*/ None,
));
}
} else if let Some(updated_tool_output) = &outcome.updated_tool_output {
let mut guard = response_cell.lock().await;
if let Some(mut result) = guard.take() {
result.result = Box::new(CallerVisibleRewriteOutput::new(
result.result,
updated_tool_output.clone(),
));
*guard = Some(result);
}
}
}

View File

@@ -1,8 +1,13 @@
use super::*;
use crate::tools::context::CallerVisibleRewriteOutput;
use crate::tools::context::McpToolOutput;
use crate::tools::handlers::GetGoalHandler;
use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME;
use crate::tools::handlers::goal_spec::create_get_goal_tool;
use codex_protocol::mcp::CallToolResult;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
struct TestHandler {
tool_name: codex_tools::ToolName,
@@ -62,6 +67,54 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
);
}
#[test]
fn caller_visible_rewrite_reaches_direct_and_code_mode_callers() {
let result = mcp_result_with_caller_visible_rewrite();
match result.into_response() {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
assert_eq!(call_id, "mcp-call-1");
assert_eq!(
output.body.to_text().as_deref(),
Some(r#"{"echo":"rewritten"}"#)
);
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
assert_eq!(
mcp_result_with_caller_visible_rewrite().code_mode_result(),
json!({
"echo": "rewritten",
})
);
}
fn mcp_result_with_caller_visible_rewrite() -> AnyToolResult {
AnyToolResult {
call_id: "mcp-call-1".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
result: Box::new(CallerVisibleRewriteOutput::new(
Box::new(McpToolOutput {
result: CallToolResult {
content: Vec::new(),
structured_content: Some(json!({ "echo": "original" })),
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: Duration::ZERO,
original_image_detail_supported: false,
truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),
}),
json!({ "echo": "rewritten" }),
)),
post_tool_use_payload: None,
}
}
#[test]
fn register_handler_adds_handler_and_spec() {
let mut builder = ToolRegistryBuilder::new();

View File

@@ -103,6 +103,34 @@ fn wait_for_file_source(path: &Path) -> Result<String> {
))
}
fn write_post_tool_use_exec_rewrite_hook(home: &Path, rewritten_output: &str) -> Result<()> {
let script_path = home.join("post_tool_use_exec_rewrite_hook.py");
let rewritten_output = serde_json::to_string(rewritten_output)?;
let script = format!(
r#"import json
import sys
json.load(sys.stdin)
print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "PostToolUse", "updatedToolOutput": {rewritten_output}}}}}))
"#
);
let hooks = serde_json::json!({
"hooks": {
"PostToolUse": [{
"matcher": "^Bash$",
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
}]
}]
}
});
fs::write(&script_path, script)?;
fs::write(home.join("hooks.json"), hooks.to_string())?;
Ok(())
}
fn custom_tool_output_body_and_success(
req: &ResponsesRequest,
call_id: &str,
@@ -322,6 +350,70 @@ text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marke
Ok(())
}
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_post_tool_use_updated_tool_output_rewrites_exec_command_output() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let rewritten_output = "[redacted]";
let mut builder = test_codex()
.with_model("test-gpt-5.1-codex")
.with_pre_build_hook(move |home| {
write_post_tool_use_exec_rewrite_hook(home, rewritten_output)
.expect("write post tool use hook");
})
.with_config(|config| {
let _ = config.features.enable(Feature::CodeMode);
let _ = config.features.enable(Feature::CodexHooks);
});
let test = builder.build(&server).await?;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(
"call-1",
"exec",
r#"
text(JSON.stringify(await tools.exec_command({ cmd: "printf original-output" })));
"#,
),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn_with_permission_profile(
"use exec to run exec_command with a post hook rewrite",
PermissionProfile::workspace_write(),
)
.await?;
let req = second_mock.single_request();
let items = custom_tool_output_items(&req, "call-1");
assert_eq!(items.len(), 2);
let parsed: Value = serde_json::from_str(text_item(&items, /*index*/ 1))?;
assert_eq!(
parsed.get("output").and_then(Value::as_str),
Some(rewritten_output),
);
assert_eq!(parsed.get("exit_code").and_then(Value::as_i64), Some(0));
assert!(parsed.get("wall_time_seconds").is_some());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_only_restricts_prompt_tools() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -547,6 +547,20 @@ elif mode == "continue_false":
"continue": False,
"stopReason": reason
}}))
elif mode == "updated_tool_output":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PostToolUse",
"updatedToolOutput": reason
}}
}}))
elif mode == "invalid_updated_tool_output":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PostToolUse",
"updatedToolOutput": {{"redacted": reason}}
}}
}}))
elif mode == "exit_2":
sys.stderr.write(reason + "\n")
raise SystemExit(2)
@@ -3360,6 +3374,143 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason(
Ok(())
}
#[tokio::test]
async fn post_tool_use_updated_tool_output_replaces_shell_command_output() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-shell-command-updated-output";
let command = "printf original-output".to_string();
let args = serde_json::json!({ "command": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "post hook output rewrite observed"),
ev_completed("resp-2"),
]),
],
)
.await;
let rewritten_output = "[redacted]";
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_post_tool_use_hook(
home,
Some("^Bash$"),
"updated_tool_output",
rewritten_output,
) {
panic!("failed to write post tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("run the shell command with output rewrite")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert_eq!(output, rewritten_output);
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
hook_inputs[0]["tool_response"],
Value::String("original-output".to_string())
);
Ok(())
}
#[tokio::test]
async fn post_tool_use_ignores_updated_tool_output_with_wrong_builtin_kind() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-shell-command-invalid-updated-output";
let command = "printf original-output".to_string();
let args = serde_json::json!({ "command": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "post hook invalid rewrite ignored"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_post_tool_use_hook(
home,
Some("^Bash$"),
"invalid_updated_tool_output",
"[redacted]",
) {
panic!("failed to write post tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("run the shell command with invalid output rewrite")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("original-output"),
"invalid replacement should preserve the original output, got {output:?}",
);
Ok(())
}
#[tokio::test]
async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_reason() -> Result<()>
{

View File

@@ -161,6 +161,48 @@ print(json.dumps({{
Ok(())
}
fn write_post_tool_use_output_hook(home: &Path) -> Result<()> {
let script_path = home.join("post_tool_use_output_hook.py");
let script = r#"import json
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": {
"content": [],
"structuredContent": {
"echo": "generic replacement"
},
"isError": False
},
"updatedMCPToolOutput": {
"content": [],
"structuredContent": {
"echo": "mcp replacement"
},
"isError": False
}
}
}))
"#;
let hooks = serde_json::json!({
"hooks": {
"PostToolUse": [{
"matcher": RMCP_HOOK_MATCHER,
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "rewriting MCP post tool use output",
}]
}]
}
});
fs::write(&script_path, script).context("write post tool use output 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>> {
fs::read_to_string(home.join(log_name))
.with_context(|| format!("read {log_name}"))?
@@ -460,3 +502,64 @@ async fn post_tool_use_records_mcp_tool_payload_and_context() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn post_tool_use_updated_tool_output_wins_for_mcp_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-rmcp-echo-output-rewrite";
let arguments = json!({ "message": RMCP_ECHO_MESSAGE }).to_string();
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(call_id, RMCP_NAMESPACE, "echo", &arguments),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "mcp post hook rewrite observed"),
ev_completed("resp-2"),
]),
],
)
.await;
let rmcp_test_server_bin = stdio_server_bin()?;
let test = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_post_tool_use_output_hook(home) {
panic!("failed to write MCP post tool use output hook fixture: {error}");
}
})
.with_config(move |config| {
enable_hooks_and_rmcp_server(config, rmcp_test_server_bin, AppToolApproval::Approve);
})
.build(&server)
.await?;
test.submit_turn("call the rmcp echo tool with the MCP post hook rewrite")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("MCP tool output string");
assert_eq!(
serde_json::from_str::<Value>(output)?,
json!({
"content": [],
"structuredContent": {
"echo": "generic replacement"
},
"isError": false,
})
);
Ok(())
}

View File

@@ -33,6 +33,9 @@
},
"updatedMCPToolOutput": {
"default": null
},
"updatedToolOutput": {
"default": null
}
},
"required": [

View File

@@ -42,6 +42,8 @@ pub(crate) struct PostToolUseOutput {
pub invalid_block_reason: Option<String>,
pub additional_context: Option<String>,
pub invalid_reason: Option<String>,
pub updated_tool_output: Option<serde_json::Value>,
pub updated_mcp_tool_output: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
@@ -188,11 +190,7 @@ pub(crate) fn parse_permission_request(stdout: &str) -> Option<PermissionRequest
pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
let wire: PostToolUseCommandOutputWire = parse_json(stdout)?;
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 invalid_reason = unsupported_post_tool_use_universal(&universal);
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
@@ -205,9 +203,16 @@ pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
} else {
None
};
let additional_context = wire
let (additional_context, updated_tool_output, updated_mcp_tool_output) = wire
.hook_specific_output
.and_then(|output| output.additional_context);
.map(|output| {
(
output.additional_context,
output.updated_tool_output,
output.updated_mcp_tool_output,
)
})
.unwrap_or_default();
Some(PostToolUseOutput {
universal,
@@ -216,6 +221,8 @@ pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
invalid_block_reason,
additional_context,
invalid_reason,
updated_tool_output,
updated_mcp_tool_output,
})
}
@@ -378,16 +385,6 @@ fn permission_request_decision(
}
}
fn unsupported_post_tool_use_hook_specific_output(
output: &crate::schema::PostToolUseHookSpecificOutputWire,
) -> Option<String> {
if output.updated_mcp_tool_output.is_some() {
Some("PostToolUse hook returned unsupported updatedMCPToolOutput".to_string())
} else {
None
}
}
fn unsupported_pre_tool_use_hook_specific_output(
output: &crate::schema::PreToolUseHookSpecificOutputWire,
) -> Option<String> {

View File

@@ -40,6 +40,7 @@ pub struct PostToolUseOutcome {
pub stop_reason: Option<String>,
pub additional_contexts: Vec<String>,
pub feedback_message: Option<String>,
pub updated_tool_output: Option<Value>,
}
#[derive(Debug, Default, PartialEq, Eq)]
@@ -48,6 +49,8 @@ struct PostToolUseHandlerData {
stop_reason: Option<String>,
additional_contexts_for_model: Vec<String>,
feedback_messages_for_model: Vec<String>,
updated_tool_output: Option<Value>,
updated_mcp_tool_output: Option<Value>,
}
pub(crate) fn preview(
@@ -85,6 +88,7 @@ pub(crate) async fn run(
stop_reason: None,
additional_contexts: Vec::new(),
feedback_message: None,
updated_tool_output: None,
};
}
@@ -101,7 +105,7 @@ pub(crate) async fn run(
}
};
let results = dispatcher::execute_handlers(
let mut results = dispatcher::execute_handlers(
shell,
matched,
input_json,
@@ -111,6 +115,8 @@ pub(crate) async fn run(
)
.await;
let updated_tool_output =
select_updated_tool_output(&mut results, &request.tool_name, &request.tool_response);
let additional_contexts = common::flatten_additional_contexts(
results
.iter()
@@ -138,6 +144,7 @@ pub(crate) async fn run(
stop_reason,
additional_contexts,
feedback_message,
updated_tool_output,
}
}
@@ -174,6 +181,8 @@ fn parse_completed(
let mut stop_reason = None;
let mut additional_contexts_for_model = Vec::new();
let mut feedback_messages_for_model = Vec::new();
let mut updated_tool_output = None;
let mut updated_mcp_tool_output = None;
match run_result.error.as_deref() {
Some(error) => {
@@ -223,17 +232,17 @@ fn parse_completed(
.and_then(common::trimmed_non_empty)
.unwrap_or(stop_text);
feedback_messages_for_model.push(model_feedback);
} else if let Some(invalid_reason) = parsed.invalid_reason {
} else if let Some(invalid_reason) = &parsed.invalid_reason {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_reason,
text: invalid_reason.clone(),
});
} else if let Some(invalid_block_reason) = parsed.invalid_block_reason {
} else if let Some(invalid_block_reason) = &parsed.invalid_block_reason {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_block_reason,
text: invalid_block_reason.clone(),
});
} else if parsed.should_block {
status = HookRunStatus::Blocked;
@@ -245,6 +254,13 @@ fn parse_completed(
feedback_messages_for_model.push(reason);
}
}
let can_rewrite_output = parsed.universal.continue_processing
&& parsed.invalid_reason.is_none()
&& parsed.invalid_block_reason.is_none();
if can_rewrite_output {
updated_tool_output = parsed.updated_tool_output;
updated_mcp_tool_output = parsed.updated_mcp_tool_output;
}
} else if output_parser::looks_like_json(&run_result.stdout) {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
@@ -297,6 +313,8 @@ fn parse_completed(
stop_reason,
additional_contexts_for_model,
feedback_messages_for_model,
updated_tool_output,
updated_mcp_tool_output,
},
completion_order: 0,
}
@@ -309,6 +327,67 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PostTo
stop_reason: None,
additional_contexts: Vec::new(),
feedback_message: None,
updated_tool_output: None,
}
}
/// Selects the final hook-provided replacement that the model should see.
///
/// For each handler, `updatedToolOutput` takes precedence over
/// `updatedMCPToolOutput`. MCP-specific replacements only apply to MCP tools,
/// MCP outputs bypass built-in shape checks, and later valid replacements win
/// over earlier ones in configured hook order.
fn select_updated_tool_output(
results: &mut [dispatcher::ParsedHandler<PostToolUseHandlerData>],
tool_name: &str,
original_tool_response: &Value,
) -> Option<Value> {
let is_mcp_tool = tool_name.starts_with("mcp__");
let mut selected = None;
for result in results {
let candidate = if let Some(updated_tool_output) = result.data.updated_tool_output.take() {
Some(updated_tool_output)
} else if is_mcp_tool {
result.data.updated_mcp_tool_output.take()
} else if result.data.updated_mcp_tool_output.is_some() {
result.completed.run.entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: "ignored updatedMCPToolOutput for non-MCP tool".to_string(),
});
None
} else {
None
};
let Some(candidate) = candidate else {
continue;
};
if is_mcp_tool || json_kind_name(original_tool_response) == json_kind_name(&candidate) {
selected = Some(candidate);
} else {
result.completed.run.entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: format!(
"ignored updatedToolOutput: expected {} to match tool_response shape",
json_kind_name(original_tool_response)
),
});
}
}
selected
}
fn json_kind_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
@@ -363,6 +442,8 @@ mod tests {
stop_reason: None,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: vec!["bash output looked sketchy".to_string()],
updated_tool_output: None,
updated_mcp_tool_output: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
@@ -387,6 +468,8 @@ mod tests {
stop_reason: None,
additional_contexts_for_model: vec!["Remember the bash cleanup note.".to_string()],
feedback_messages_for_model: Vec::new(),
updated_tool_output: None,
updated_mcp_tool_output: None,
}
);
assert_eq!(
@@ -399,7 +482,7 @@ mod tests {
}
#[test]
fn unsupported_updated_mcp_tool_output_fails_open() {
fn updated_mcp_tool_output_is_recorded() {
let parsed = parse_completed(
&handler(),
run_result(
@@ -417,16 +500,12 @@ mod tests {
stop_reason: None,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: Vec::new(),
updated_tool_output: None,
updated_mcp_tool_output: Some(json!({"ok": true})),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PostToolUse hook returned unsupported updatedMCPToolOutput".to_string(),
}]
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(parsed.completed.run.entries, Vec::<HookOutputEntry>::new());
}
#[test]
@@ -444,6 +523,8 @@ mod tests {
stop_reason: None,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: vec!["post hook says pause".to_string()],
updated_tool_output: None,
updated_mcp_tool_output: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
@@ -468,6 +549,8 @@ mod tests {
stop_reason: Some("halt after bash output".to_string()),
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: vec!["post-tool hook says stop".to_string()],
updated_tool_output: None,
updated_mcp_tool_output: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
@@ -495,6 +578,8 @@ mod tests {
stop_reason: None,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: Vec::new(),
updated_tool_output: None,
updated_mcp_tool_output: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
@@ -541,6 +626,97 @@ mod tests {
assert_eq!(completed[0].run.id, runs[0].id);
}
#[test]
fn select_updated_tool_output_prefers_last_valid_replacement() {
let mut results = vec![
parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":"first"}}"#,
"",
),
Some("turn-1".to_string()),
),
parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":"second"}}"#,
"",
),
Some("turn-1".to_string()),
),
];
assert_eq!(
super::select_updated_tool_output(&mut results, "Bash", &json!("old")),
Some(json!("second"))
);
}
#[test]
fn select_updated_tool_output_ignores_wrong_builtin_json_kind() {
let mut results = vec![parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"ok":true}}}"#,
"",
),
Some("turn-1".to_string()),
)];
assert_eq!(
super::select_updated_tool_output(&mut results, "Bash", &json!("old")),
None
);
assert_eq!(
results[0].completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: "ignored updatedToolOutput: expected string to match tool_response shape"
.to_string(),
}]
);
}
#[test]
fn select_updated_tool_output_prefers_generic_mcp_replacement() {
let mut results = vec![parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"source":"generic"},"updatedMCPToolOutput":{"source":"mcp"}}}"#,
"",
),
Some("turn-1".to_string()),
)];
assert_eq!(
super::select_updated_tool_output(&mut results, "mcp__memory__lookup", &json!({})),
Some(json!({"source": "generic"}))
);
}
#[test]
fn select_updated_tool_output_accepts_mcp_fallback_replacement() {
let mut results = vec![parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedMCPToolOutput":{"source":"mcp"}}}"#,
"",
),
Some("turn-1".to_string()),
)];
assert_eq!(
super::select_updated_tool_output(&mut results, "mcp__memory__lookup", &json!({})),
Some(json!({"source": "mcp"}))
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::PostToolUse,

View File

@@ -199,6 +199,8 @@ pub(crate) struct PostToolUseHookSpecificOutputWire {
#[serde(default)]
pub additional_context: Option<String>,
#[serde(default)]
pub updated_tool_output: Option<Value>,
#[serde(default)]
#[serde(rename = "updatedMCPToolOutput")]
pub updated_mcp_tool_output: Option<Value>,
}