Add hooks for interrupted turns (#40511)

## What changed

- Add an `Interrupt` hook event that runs for an active top-level turn before its
  interrupted abort event is emitted.
- Flush the turn transcript before invoking the hook and provide the session,
  turn, transcript, working directory, model, and permission mode in its input.
- Support command and MCP handlers, including asynchronous commands, with a
  one-second default timeout and a three-second maximum.
- Expose the event through hook configuration, managed requirements, app-server
  notifications, generated schemas, analytics, and the TUI hook views.

## Testing

- Cover handler discovery, timeout normalization, output parsing, protocol
  compatibility, TUI rendering, and interrupt execution ordering.

GitOrigin-RevId: 163fa7c098d94ac2775f6d137f8e916f8ea9b6eb
This commit is contained in:
Andrei Eternal
2026-08-25 01:27:18 +00:00
committed by copyberry
parent 9b2ef38f54
commit cbfd999db7
49 changed files with 1393 additions and 48 deletions
+104
View File
@@ -45,6 +45,7 @@ use super::ConfiguredHandler;
use super::ConfiguredHandlerKind;
use super::HandlerSourcePath;
use super::HookListEntryHandler;
use crate::events::interrupt::InterruptRequest;
use crate::events::pre_tool_use::PreToolUseRequest;
use crate::events::stop::StopHookTarget;
use crate::events::stop::StopRequest;
@@ -2359,3 +2360,106 @@ async fn mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions() {
}]
);
}
#[tokio::test]
async fn mcp_interrupt_hooks_expand_event_input_and_bound_timeout() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
fs::write(
temp.path().join("hooks.json"),
serde_json::json!({
"hooks": {
"Interrupt": [{
"hooks": [{
"type": "mcp_tool",
"server": "security",
"tool": "notify",
"input": {
"event": "${hook_event_name}",
"turn_id": "${turn_id}",
"permission_mode": "${permission_mode}",
},
"timeout": 20,
}],
}],
},
})
.to_string(),
)
.expect("write MCP Interrupt hooks.json");
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_path,
profile: None,
},
TomlValue::Table(Default::default()),
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let calls = Arc::new(Mutex::new(Vec::new()));
let executor = StaticMcpExecutor {
calls: Arc::clone(&calls),
output: serde_json::json!({
"systemMessage": "interrupt observed",
})
.to_string(),
outputs_by_tool: HashMap::new(),
};
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*bypass_hook_trust*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
command_runtime(CommandShell {
program: String::new(),
args: Vec::new(),
}),
Arc::new(executor),
);
let outcome = engine
.run_interrupt(InterruptRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
})
.await;
assert_eq!(outcome.hook_events.len(), 1);
assert_eq!(
outcome.hook_events[0].run.handler_type,
HookHandlerType::McpTool
);
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed);
assert_eq!(
outcome.hook_events[0].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: "interrupt observed".to_string(),
}]
);
assert_eq!(
*calls.lock().expect("lock MCP calls"),
vec![HookMcpCall {
server: "security".to_string(),
tool: "notify".to_string(),
environment_id: None,
metadata: None,
input: serde_json::from_value(serde_json::json!({
"event": "Interrupt",
"turn_id": "turn-1",
"permission_mode": "default",
}))
.expect("object input"),
timeout: Duration::from_secs(3),
}]
);
}