mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
Compare commits
8 Commits
dev/efraze
...
abhinav/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea83813ef | ||
|
|
5fb75cf27f | ||
|
|
671a09536b | ||
|
|
76cd705327 | ||
|
|
b06ef69f90 | ||
|
|
365bf45526 | ||
|
|
a041fd668b | ||
|
|
fa92ad8b0e |
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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<()>
|
||||
{
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
},
|
||||
"updatedMCPToolOutput": {
|
||||
"default": null
|
||||
},
|
||||
"updatedToolOutput": {
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user