Skip to content
4 changes: 3 additions & 1 deletion e2e-tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ def event_loop_policy():

def pytest_configure(config):
"""Add e2e marker."""
config.addinivalue_line("markers", "e2e: marks tests as e2e tests requiring API key")
config.addinivalue_line(
"markers", "e2e: marks tests as e2e tests requiring API key"
)
196 changes: 196 additions & 0 deletions e2e-tests/test_hook_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""End-to-end tests for hook event types with real Claude API calls."""

from typing import Any

import pytest

from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
HookContext,
HookInput,
HookJSONOutput,
HookMatcher,
)


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_pre_tool_use_hook_with_additional_context():
"""Test PreToolUse hook returning additionalContext field end-to-end."""
hook_invocations: list[dict[str, Any]] = []

async def pre_tool_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""PreToolUse hook that provides additionalContext."""
tool_name = input_data.get("tool_name", "")
hook_invocations.append(
{"tool_name": tool_name, "tool_use_id": input_data.get("tool_use_id")}
)

return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Approved with context",
"additionalContext": "This command is running in a test environment",
},
}

options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[pre_tool_hook]),
],
},
)

async with ClaudeSDKClient(options=options) as client:
await client.query("Run: echo 'test additional context'")

async for message in client.receive_response():
print(f"Got message: {message}")

print(f"Hook invocations: {hook_invocations}")
assert len(hook_invocations) > 0, "PreToolUse hook should have been invoked"
# Verify tool_use_id is present in the input (new field)
assert hook_invocations[0]["tool_use_id"] is not None, (
"tool_use_id should be present in PreToolUse input"
)


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_post_tool_use_hook_with_tool_use_id():
"""Test PostToolUse hook receives tool_use_id field end-to-end."""
hook_invocations: list[dict[str, Any]] = []

async def post_tool_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""PostToolUse hook that verifies tool_use_id is present."""
tool_name = input_data.get("tool_name", "")
hook_invocations.append(
{
"tool_name": tool_name,
"tool_use_id": input_data.get("tool_use_id"),
}
)

return {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Post-tool monitoring active",
},
}

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 tool_use_id'")

async for message in client.receive_response():
print(f"Got message: {message}")

print(f"Hook invocations: {hook_invocations}")
assert len(hook_invocations) > 0, "PostToolUse hook should have been invoked"
# Verify tool_use_id is present in the input (new field)
assert hook_invocations[0]["tool_use_id"] is not None, (
"tool_use_id should be present in PostToolUse input"
)


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_notification_hook():
"""Test Notification hook fires end-to-end."""
hook_invocations: list[dict[str, Any]] = []

async def notification_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Notification hook that tracks invocations."""
hook_invocations.append(
{
"hook_event_name": input_data.get("hook_event_name"),
"message": input_data.get("message"),
"notification_type": input_data.get("notification_type"),
}
)
return {
"hookSpecificOutput": {
"hookEventName": "Notification",
"additionalContext": "Notification received",
},
}

options = ClaudeAgentOptions(
hooks={
"Notification": [
HookMatcher(hooks=[notification_hook]),
],
},
)

async with ClaudeSDKClient(options=options) as client:
await client.query("Say hello in one word.")

async for message in client.receive_response():
print(f"Got message: {message}")

print(f"Notification hook invocations: {hook_invocations}")
# Notification hooks may or may not fire depending on CLI behavior.
# This test verifies the hook registration doesn't cause errors.
# If it fires, verify the shape is correct.
for invocation in hook_invocations:
assert invocation["hook_event_name"] == "Notification"
assert invocation["notification_type"] is not None


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_multiple_hooks_together():
"""Test registering multiple hook event types together end-to-end."""
all_invocations: list[dict[str, Any]] = []

async def track_hook(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Generic hook that tracks all invocations."""
all_invocations.append(
{
"hook_event_name": input_data.get("hook_event_name"),
}
)
return {}

options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"Notification": [HookMatcher(hooks=[track_hook])],
"PreToolUse": [HookMatcher(matcher="Bash", hooks=[track_hook])],
"PostToolUse": [HookMatcher(matcher="Bash", hooks=[track_hook])],
},
)

async with ClaudeSDKClient(options=options) as client:
await client.query("Run: echo 'multi-hook test'")

async for message in client.receive_response():
print(f"Got message: {message}")

print(f"All hook invocations: {all_invocations}")
event_names = [inv["hook_event_name"] for inv in all_invocations]

# At minimum, PreToolUse and PostToolUse should fire for the Bash command
assert "PreToolUse" in event_names, "PreToolUse hook should have fired"
assert "PostToolUse" in event_names, "PostToolUse hook should have fired"
12 changes: 9 additions & 3 deletions e2e-tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ async def test_hook(

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}"
assert "Bash" in hook_invocations, (
f"Hook should have been invoked for Bash tool, got: {hook_invocations}"
)


@pytest.mark.e2e
Expand Down Expand Up @@ -105,7 +107,9 @@ async def post_tool_hook(

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}"
assert "Bash" in hook_invocations, (
f"PostToolUse hook should have been invoked, got: {hook_invocations}"
)


@pytest.mark.e2e
Expand Down Expand Up @@ -147,4 +151,6 @@ async def context_hook(

print(f"Hook invocations: {hook_invocations}")
# Verify hook was called
assert "context_added" in hook_invocations, "Hook with hookSpecificOutput should have been invoked"
assert "context_added" in hook_invocations, (
"Hook with hookSpecificOutput should have been invoked"
)
23 changes: 13 additions & 10 deletions e2e-tests/test_include_partial_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,19 @@
including StreamEvent parsing and message interleaving.
"""

import asyncio
from typing import List, Any
from typing import Any

import pytest

from claude_agent_sdk import ClaudeSDKClient
from claude_agent_sdk.types import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
StreamEvent,
AssistantMessage,
SystemMessage,
ResultMessage,
ThinkingBlock,
TextBlock,
ThinkingBlock,
)


Expand All @@ -35,7 +34,7 @@ async def test_include_partial_messages_stream_events():
},
)

collected_messages: List[Any] = []
collected_messages: list[Any] = []

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

# Should have AssistantMessage messages with thinking and text
assistant_messages = [msg for msg in collected_messages if isinstance(msg, AssistantMessage)]
assistant_messages = [
msg for msg in collected_messages if isinstance(msg, AssistantMessage)
]
assert len(assistant_messages) >= 1, "No AssistantMessage received"

# Check for thinking block in at least one AssistantMessage
Expand Down Expand Up @@ -136,7 +137,7 @@ async def test_partial_messages_disabled_by_default():
max_turns=2,
)

collected_messages: List[Any] = []
collected_messages: list[Any] = []

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

# Should NOT have any StreamEvent messages
stream_events = [msg for msg in collected_messages if isinstance(msg, StreamEvent)]
assert len(stream_events) == 0, "StreamEvent messages present when partial messages disabled"
assert len(stream_events) == 0, (
"StreamEvent messages present when partial messages disabled"
)

# Should still have the regular messages
assert any(isinstance(msg, SystemMessage) for msg in collected_messages)
assert any(isinstance(msg, AssistantMessage) for msg in collected_messages)
assert any(isinstance(msg, ResultMessage) for msg in collected_messages)
assert any(isinstance(msg, ResultMessage) for msg in collected_messages)
9 changes: 5 additions & 4 deletions e2e-tests/test_stderr_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ def capture_stderr(line: str):

# Enable debug mode to generate stderr output
options = ClaudeAgentOptions(
stderr=capture_stderr,
extra_args={"debug-to-stderr": None}
stderr=capture_stderr, extra_args={"debug-to-stderr": None}
)

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

# Verify we captured debug output
assert len(stderr_lines) > 0, "Should capture stderr output with debug enabled"
assert any("[DEBUG]" in line for line in stderr_lines), "Should contain DEBUG messages"
assert any("[DEBUG]" in line for line in stderr_lines), (
"Should contain DEBUG messages"
)


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

# Should work but capture minimal/no output without debug
assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode"
assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode"
4 changes: 3 additions & 1 deletion e2e-tests/test_structured_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ async def test_simple_structured_output():
assert result_message.subtype == "success"

# Verify structured output is present and valid
assert result_message.structured_output is not None, "No structured output in result"
assert result_message.structured_output is not None, (
"No structured output in result"
)
assert "file_count" in result_message.structured_output
assert "has_tests" in result_message.structured_output
assert isinstance(result_message.structured_output["file_count"], (int, float))
Expand Down
12 changes: 12 additions & 0 deletions src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@
McpSdkServerConfig,
McpServerConfig,
Message,
NotificationHookInput,
NotificationHookSpecificOutput,
PermissionMode,
PermissionRequestHookInput,
PermissionRequestHookSpecificOutput,
PermissionResult,
PermissionResultAllow,
PermissionResultDeny,
Expand All @@ -48,6 +52,8 @@
SdkPluginConfig,
SettingSource,
StopHookInput,
SubagentStartHookInput,
SubagentStartHookSpecificOutput,
SubagentStopHookInput,
SystemMessage,
TextBlock,
Expand Down Expand Up @@ -343,6 +349,12 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"StopHookInput",
"SubagentStopHookInput",
"PreCompactHookInput",
"NotificationHookInput",
"SubagentStartHookInput",
"PermissionRequestHookInput",
"NotificationHookSpecificOutput",
"SubagentStartHookSpecificOutput",
"PermissionRequestHookSpecificOutput",
"HookJSONOutput",
"HookMatcher",
# Agent support
Expand Down
Loading