Skip to content

Commit 1a832b8

Browse files
ashwin-antclaude
andauthored
feat: add missing hook events and fix existing hook fields (#545)
## Summary Add 3 new hook events to `HookEvent` (`Notification`, `SubagentStart`, `PermissionRequest`) with their input/output types. Also fix missing fields on existing hooks. ## New hook events - `Notification` — with `NotificationHookInput` and `NotificationHookSpecificOutput` - `SubagentStart` — with `SubagentStartHookInput` and `SubagentStartHookSpecificOutput` - `PermissionRequest` — with `PermissionRequestHookInput` and `PermissionRequestHookSpecificOutput` ## Fixed fields on existing hooks - `PreToolUseHookInput`: added `tool_use_id` - `PostToolUseHookInput`: added `tool_use_id` - `SubagentStopHookInput`: added `agent_id`, `agent_transcript_path`, `agent_type` - `PreToolUseHookSpecificOutput`: added `additionalContext` - `PostToolUseHookSpecificOutput`: added `updatedMCPToolOutput` ## Not included `SessionStart`, `SessionEnd`, and `Setup` are not added — they fire in the CLI before the SDK connection is established, so SDK callbacks for them would never execute. ## Tests - Unit tests for all new type constructions and hook callback flows - E2e tests for `PreToolUse` (additionalContext, tool_use_id), `PostToolUse` (tool_use_id), `Notification`, and multi-hook registration --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7b2f006 commit 1a832b8

10 files changed

Lines changed: 714 additions & 21 deletions

e2e-tests/conftest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ def event_loop_policy():
2727

2828
def pytest_configure(config):
2929
"""Add e2e marker."""
30-
config.addinivalue_line("markers", "e2e: marks tests as e2e tests requiring API key")
30+
config.addinivalue_line(
31+
"markers", "e2e: marks tests as e2e tests requiring API key"
32+
)

e2e-tests/test_hook_events.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""End-to-end tests for hook event types with real Claude API calls."""
2+
3+
from typing import Any
4+
5+
import pytest
6+
7+
from claude_agent_sdk import (
8+
ClaudeAgentOptions,
9+
ClaudeSDKClient,
10+
HookContext,
11+
HookInput,
12+
HookJSONOutput,
13+
HookMatcher,
14+
)
15+
16+
17+
@pytest.mark.e2e
18+
@pytest.mark.asyncio
19+
async def test_pre_tool_use_hook_with_additional_context():
20+
"""Test PreToolUse hook returning additionalContext field end-to-end."""
21+
hook_invocations: list[dict[str, Any]] = []
22+
23+
async def pre_tool_hook(
24+
input_data: HookInput, tool_use_id: str | None, context: HookContext
25+
) -> HookJSONOutput:
26+
"""PreToolUse hook that provides additionalContext."""
27+
tool_name = input_data.get("tool_name", "")
28+
hook_invocations.append(
29+
{"tool_name": tool_name, "tool_use_id": input_data.get("tool_use_id")}
30+
)
31+
32+
return {
33+
"hookSpecificOutput": {
34+
"hookEventName": "PreToolUse",
35+
"permissionDecision": "allow",
36+
"permissionDecisionReason": "Approved with context",
37+
"additionalContext": "This command is running in a test environment",
38+
},
39+
}
40+
41+
options = ClaudeAgentOptions(
42+
allowed_tools=["Bash"],
43+
hooks={
44+
"PreToolUse": [
45+
HookMatcher(matcher="Bash", hooks=[pre_tool_hook]),
46+
],
47+
},
48+
)
49+
50+
async with ClaudeSDKClient(options=options) as client:
51+
await client.query("Run: echo 'test additional context'")
52+
53+
async for message in client.receive_response():
54+
print(f"Got message: {message}")
55+
56+
print(f"Hook invocations: {hook_invocations}")
57+
assert len(hook_invocations) > 0, "PreToolUse hook should have been invoked"
58+
# Verify tool_use_id is present in the input (new field)
59+
assert hook_invocations[0]["tool_use_id"] is not None, (
60+
"tool_use_id should be present in PreToolUse input"
61+
)
62+
63+
64+
@pytest.mark.e2e
65+
@pytest.mark.asyncio
66+
async def test_post_tool_use_hook_with_tool_use_id():
67+
"""Test PostToolUse hook receives tool_use_id field end-to-end."""
68+
hook_invocations: list[dict[str, Any]] = []
69+
70+
async def post_tool_hook(
71+
input_data: HookInput, tool_use_id: str | None, context: HookContext
72+
) -> HookJSONOutput:
73+
"""PostToolUse hook that verifies tool_use_id is present."""
74+
tool_name = input_data.get("tool_name", "")
75+
hook_invocations.append(
76+
{
77+
"tool_name": tool_name,
78+
"tool_use_id": input_data.get("tool_use_id"),
79+
}
80+
)
81+
82+
return {
83+
"hookSpecificOutput": {
84+
"hookEventName": "PostToolUse",
85+
"additionalContext": "Post-tool monitoring active",
86+
},
87+
}
88+
89+
options = ClaudeAgentOptions(
90+
allowed_tools=["Bash"],
91+
hooks={
92+
"PostToolUse": [
93+
HookMatcher(matcher="Bash", hooks=[post_tool_hook]),
94+
],
95+
},
96+
)
97+
98+
async with ClaudeSDKClient(options=options) as client:
99+
await client.query("Run: echo 'test tool_use_id'")
100+
101+
async for message in client.receive_response():
102+
print(f"Got message: {message}")
103+
104+
print(f"Hook invocations: {hook_invocations}")
105+
assert len(hook_invocations) > 0, "PostToolUse hook should have been invoked"
106+
# Verify tool_use_id is present in the input (new field)
107+
assert hook_invocations[0]["tool_use_id"] is not None, (
108+
"tool_use_id should be present in PostToolUse input"
109+
)
110+
111+
112+
@pytest.mark.e2e
113+
@pytest.mark.asyncio
114+
async def test_notification_hook():
115+
"""Test Notification hook fires end-to-end."""
116+
hook_invocations: list[dict[str, Any]] = []
117+
118+
async def notification_hook(
119+
input_data: HookInput, tool_use_id: str | None, context: HookContext
120+
) -> HookJSONOutput:
121+
"""Notification hook that tracks invocations."""
122+
hook_invocations.append(
123+
{
124+
"hook_event_name": input_data.get("hook_event_name"),
125+
"message": input_data.get("message"),
126+
"notification_type": input_data.get("notification_type"),
127+
}
128+
)
129+
return {
130+
"hookSpecificOutput": {
131+
"hookEventName": "Notification",
132+
"additionalContext": "Notification received",
133+
},
134+
}
135+
136+
options = ClaudeAgentOptions(
137+
hooks={
138+
"Notification": [
139+
HookMatcher(hooks=[notification_hook]),
140+
],
141+
},
142+
)
143+
144+
async with ClaudeSDKClient(options=options) as client:
145+
await client.query("Say hello in one word.")
146+
147+
async for message in client.receive_response():
148+
print(f"Got message: {message}")
149+
150+
print(f"Notification hook invocations: {hook_invocations}")
151+
# Notification hooks may or may not fire depending on CLI behavior.
152+
# This test verifies the hook registration doesn't cause errors.
153+
# If it fires, verify the shape is correct.
154+
for invocation in hook_invocations:
155+
assert invocation["hook_event_name"] == "Notification"
156+
assert invocation["notification_type"] is not None
157+
158+
159+
@pytest.mark.e2e
160+
@pytest.mark.asyncio
161+
async def test_multiple_hooks_together():
162+
"""Test registering multiple hook event types together end-to-end."""
163+
all_invocations: list[dict[str, Any]] = []
164+
165+
async def track_hook(
166+
input_data: HookInput, tool_use_id: str | None, context: HookContext
167+
) -> HookJSONOutput:
168+
"""Generic hook that tracks all invocations."""
169+
all_invocations.append(
170+
{
171+
"hook_event_name": input_data.get("hook_event_name"),
172+
}
173+
)
174+
return {}
175+
176+
options = ClaudeAgentOptions(
177+
allowed_tools=["Bash"],
178+
hooks={
179+
"Notification": [HookMatcher(hooks=[track_hook])],
180+
"PreToolUse": [HookMatcher(matcher="Bash", hooks=[track_hook])],
181+
"PostToolUse": [HookMatcher(matcher="Bash", hooks=[track_hook])],
182+
},
183+
)
184+
185+
async with ClaudeSDKClient(options=options) as client:
186+
await client.query("Run: echo 'multi-hook test'")
187+
188+
async for message in client.receive_response():
189+
print(f"Got message: {message}")
190+
191+
print(f"All hook invocations: {all_invocations}")
192+
event_names = [inv["hook_event_name"] for inv in all_invocations]
193+
194+
# At minimum, PreToolUse and PostToolUse should fire for the Bash command
195+
assert "PreToolUse" in event_names, "PreToolUse hook should have fired"
196+
assert "PostToolUse" in event_names, "PostToolUse hook should have fired"

e2e-tests/test_hooks.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ async def test_hook(
6464

6565
print(f"Hook invocations: {hook_invocations}")
6666
# Verify hook was called
67-
assert "Bash" in hook_invocations, f"Hook should have been invoked for Bash tool, got: {hook_invocations}"
67+
assert "Bash" in hook_invocations, (
68+
f"Hook should have been invoked for Bash tool, got: {hook_invocations}"
69+
)
6870

6971

7072
@pytest.mark.e2e
@@ -105,7 +107,9 @@ async def post_tool_hook(
105107

106108
print(f"Hook invocations: {hook_invocations}")
107109
# Verify hook was called
108-
assert "Bash" in hook_invocations, f"PostToolUse hook should have been invoked, got: {hook_invocations}"
110+
assert "Bash" in hook_invocations, (
111+
f"PostToolUse hook should have been invoked, got: {hook_invocations}"
112+
)
109113

110114

111115
@pytest.mark.e2e
@@ -147,4 +151,6 @@ async def context_hook(
147151

148152
print(f"Hook invocations: {hook_invocations}")
149153
# Verify hook was called
150-
assert "context_added" in hook_invocations, "Hook with hookSpecificOutput should have been invoked"
154+
assert "context_added" in hook_invocations, (
155+
"Hook with hookSpecificOutput should have been invoked"
156+
)

e2e-tests/test_include_partial_messages.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,19 @@
44
including StreamEvent parsing and message interleaving.
55
"""
66

7-
import asyncio
8-
from typing import List, Any
7+
from typing import Any
98

109
import pytest
1110

1211
from claude_agent_sdk import ClaudeSDKClient
1312
from claude_agent_sdk.types import (
13+
AssistantMessage,
1414
ClaudeAgentOptions,
15+
ResultMessage,
1516
StreamEvent,
16-
AssistantMessage,
1717
SystemMessage,
18-
ResultMessage,
19-
ThinkingBlock,
2018
TextBlock,
19+
ThinkingBlock,
2120
)
2221

2322

@@ -35,7 +34,7 @@ async def test_include_partial_messages_stream_events():
3534
},
3635
)
3736

38-
collected_messages: List[Any] = []
37+
collected_messages: list[Any] = []
3938

4039
async with ClaudeSDKClient(options) as client:
4140
# Send a simple prompt that will generate streaming response with thinking
@@ -65,7 +64,9 @@ async def test_include_partial_messages_stream_events():
6564
assert "message_stop" in event_types, "No message_stop StreamEvent"
6665

6766
# Should have AssistantMessage messages with thinking and text
68-
assistant_messages = [msg for msg in collected_messages if isinstance(msg, AssistantMessage)]
67+
assistant_messages = [
68+
msg for msg in collected_messages if isinstance(msg, AssistantMessage)
69+
]
6970
assert len(assistant_messages) >= 1, "No AssistantMessage received"
7071

7172
# Check for thinking block in at least one AssistantMessage
@@ -136,7 +137,7 @@ async def test_partial_messages_disabled_by_default():
136137
max_turns=2,
137138
)
138139

139-
collected_messages: List[Any] = []
140+
collected_messages: list[Any] = []
140141

141142
async with ClaudeSDKClient(options) as client:
142143
await client.query("Say hello")
@@ -146,9 +147,11 @@ async def test_partial_messages_disabled_by_default():
146147

147148
# Should NOT have any StreamEvent messages
148149
stream_events = [msg for msg in collected_messages if isinstance(msg, StreamEvent)]
149-
assert len(stream_events) == 0, "StreamEvent messages present when partial messages disabled"
150+
assert len(stream_events) == 0, (
151+
"StreamEvent messages present when partial messages disabled"
152+
)
150153

151154
# Should still have the regular messages
152155
assert any(isinstance(msg, SystemMessage) for msg in collected_messages)
153156
assert any(isinstance(msg, AssistantMessage) for msg in collected_messages)
154-
assert any(isinstance(msg, ResultMessage) for msg in collected_messages)
157+
assert any(isinstance(msg, ResultMessage) for msg in collected_messages)

e2e-tests/test_stderr_callback.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ def capture_stderr(line: str):
1616

1717
# Enable debug mode to generate stderr output
1818
options = ClaudeAgentOptions(
19-
stderr=capture_stderr,
20-
extra_args={"debug-to-stderr": None}
19+
stderr=capture_stderr, extra_args={"debug-to-stderr": None}
2120
)
2221

2322
# Run a simple query
@@ -26,7 +25,9 @@ def capture_stderr(line: str):
2625

2726
# Verify we captured debug output
2827
assert len(stderr_lines) > 0, "Should capture stderr output with debug enabled"
29-
assert any("[DEBUG]" in line for line in stderr_lines), "Should contain DEBUG messages"
28+
assert any("[DEBUG]" in line for line in stderr_lines), (
29+
"Should contain DEBUG messages"
30+
)
3031

3132

3233
@pytest.mark.e2e
@@ -46,4 +47,4 @@ def capture_stderr(line: str):
4647
pass # Just consume messages
4748

4849
# Should work but capture minimal/no output without debug
49-
assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode"
50+
assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode"

e2e-tests/test_structured_output.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ async def test_simple_structured_output():
5252
assert result_message.subtype == "success"
5353

5454
# Verify structured output is present and valid
55-
assert result_message.structured_output is not None, "No structured output in result"
55+
assert result_message.structured_output is not None, (
56+
"No structured output in result"
57+
)
5658
assert "file_count" in result_message.structured_output
5759
assert "has_tests" in result_message.structured_output
5860
assert isinstance(result_message.structured_output["file_count"], (int, float))

src/claude_agent_sdk/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
McpSdkServerConfig,
3131
McpServerConfig,
3232
Message,
33+
NotificationHookInput,
34+
NotificationHookSpecificOutput,
3335
PermissionMode,
36+
PermissionRequestHookInput,
37+
PermissionRequestHookSpecificOutput,
3438
PermissionResult,
3539
PermissionResultAllow,
3640
PermissionResultDeny,
@@ -48,6 +52,8 @@
4852
SdkPluginConfig,
4953
SettingSource,
5054
StopHookInput,
55+
SubagentStartHookInput,
56+
SubagentStartHookSpecificOutput,
5157
SubagentStopHookInput,
5258
SystemMessage,
5359
TextBlock,
@@ -343,6 +349,12 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
343349
"StopHookInput",
344350
"SubagentStopHookInput",
345351
"PreCompactHookInput",
352+
"NotificationHookInput",
353+
"SubagentStartHookInput",
354+
"PermissionRequestHookInput",
355+
"NotificationHookSpecificOutput",
356+
"SubagentStartHookSpecificOutput",
357+
"PermissionRequestHookSpecificOutput",
346358
"HookJSONOutput",
347359
"HookMatcher",
348360
# Agent support

0 commit comments

Comments
 (0)