claude-code-sdk-python/e2e-tests/test_hooks.py
Ashwin Bhat d754e5cc1d
feat: add strongly-typed hook inputs with TypedDict (#240)
Add typed hook input structures (PreToolUseHookInput,
PostToolUseHookInput, etc.) to provide better IDE autocomplete and type
safety for hook callbacks. Also convert HookContext from dataclass to
TypedDict to match runtime behavior.

Changes:
- Add BaseHookInput, PreToolUseHookInput, PostToolUseHookInput,
UserPromptSubmitHookInput, StopHookInput, SubagentStopHookInput, and
PreCompactHookInput TypedDict classes
- Update HookCallback signature to use HookInput union type
- Convert HookContext from dataclass to TypedDict (fixes type mismatch)
- Export all new hook input types from __init__.py
- Update all examples and tests to use typed hook inputs

Benefits:
- Zero breaking changes (TypedDict is dict-compatible at runtime)
- Full type safety and IDE autocomplete for hook callbacks
- Matches TypeScript SDK structure exactly
- Self-documenting hook input fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-10 16:22:13 -07:00

150 lines
5 KiB
Python

"""End-to-end tests for hook callbacks with real Claude API calls."""
import pytest
from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
HookContext,
HookInput,
HookJSONOutput,
HookMatcher,
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_hook_with_permission_decision_and_reason():
"""Test that hooks with permissionDecision and reason fields work end-to-end."""
hook_invocations = []
async def test_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Hook that uses permissionDecision and reason fields."""
tool_name = input_data.get("tool_name", "")
print(f"Hook called for tool: {tool_name}")
hook_invocations.append(tool_name)
# Block Bash commands for this test
if tool_name == "Bash":
return {
"reason": "Bash commands are blocked in this test for safety",
"systemMessage": "⚠️ Command blocked by hook",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Security policy: Bash blocked",
},
}
return {
"reason": "Tool approved by security review",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Tool passed security checks",
},
}
options = ClaudeAgentOptions(
allowed_tools=["Bash", "Write"],
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[test_hook]),
],
},
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Run this bash command: echo 'hello'")
async for message in client.receive_response():
print(f"Got message: {message}")
print(f"Hook invocations: {hook_invocations}")
# Verify hook was called
assert "Bash" in hook_invocations, f"Hook should have been invoked for Bash tool, got: {hook_invocations}"
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_hook_with_continue_and_stop_reason():
"""Test that hooks with continue_=False and stopReason fields work end-to-end."""
hook_invocations = []
async def post_tool_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""PostToolUse hook that stops execution with stopReason."""
tool_name = input_data.get("tool_name", "")
hook_invocations.append(tool_name)
# Actually test continue_=False and stopReason fields
return {
"continue_": False,
"stopReason": "Execution halted by test hook for validation",
"reason": "Testing continue and stopReason fields",
"systemMessage": "🛑 Test hook stopped execution",
}
options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"PostToolUse": [
HookMatcher(matcher="Bash", hooks=[post_tool_hook]),
],
},
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Run: echo 'test message'")
async for message in client.receive_response():
print(f"Got message: {message}")
print(f"Hook invocations: {hook_invocations}")
# Verify hook was called
assert "Bash" in hook_invocations, f"PostToolUse hook should have been invoked, got: {hook_invocations}"
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_hook_with_additional_context():
"""Test that hooks with hookSpecificOutput work end-to-end."""
hook_invocations = []
async def context_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Hook that provides additional context."""
hook_invocations.append("context_added")
return {
"systemMessage": "Additional context provided by hook",
"reason": "Hook providing monitoring feedback",
"suppressOutput": False,
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "The command executed successfully with hook monitoring",
},
}
options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"PostToolUse": [
HookMatcher(matcher="Bash", hooks=[context_hook]),
],
},
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Run: echo 'testing hooks'")
async for message in client.receive_response():
print(f"Got message: {message}")
print(f"Hook invocations: {hook_invocations}")
# Verify hook was called
assert "context_added" in hook_invocations, "Hook with hookSpecificOutput should have been invoked"