Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
ContextUsageResponse,
HookCallback,
HookContext,
HookEventMessage,
HookInput,
HookJSONOutput,
HookMatcher,
Expand Down Expand Up @@ -577,6 +578,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"HookCallback",
"HookContext",
"HookInput",
"HookEventMessage",
"BaseHookInput",
"PreToolUseHookInput",
"PostToolUseHookInput",
Expand Down
23 changes: 23 additions & 0 deletions src/claude_agent_sdk/_internal/message_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ..types import (
AssistantMessage,
ContentBlock,
HookEventMessage,
Message,
MirrorErrorMessage,
RateLimitEvent,
Expand Down Expand Up @@ -48,6 +49,28 @@ def parse_message(data: dict[str, Any]) -> Message | None:
data,
)

# Hook events (emitted when ``include_hook_events`` is enabled) arrive as
# ``system`` messages with ``subtype`` of ``hook_started`` or
# ``hook_response``. Route them to ``HookEventMessage`` before the generic
# ``SystemMessage`` handling below.
if data.get("type") == "system" and data.get("subtype") in (
"hook_started",
"hook_response",
):
hook_event_name = (
data.get("hook_event")
or data.get("hook_name")
or data.get("hook_event_name")
or ""
)
return HookEventMessage(
subtype=data["subtype"],
hook_event_name=hook_event_name,
data=data,
session_id=data.get("session_id"),
uuid=data.get("uuid"),
)

message_type = data.get("type")
if not message_type:
raise MessageParseError("Message missing 'type' field", data)
Expand Down
3 changes: 3 additions & 0 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ def _build_command(self) -> list[str]:
if self._options.include_partial_messages:
cmd.append("--include-partial-messages")

if self._options.include_hook_events:
cmd.append("--include-hook-events")

if self._options.fork_session:
cmd.append("--fork-session")

Expand Down
42 changes: 42 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,40 @@ class RateLimitEvent:
session_id: str


@dataclass
class HookEventMessage(SystemMessage):
"""Hook event emitted by the CLI when ``include_hook_events`` is enabled.

When ``ClaudeAgentOptions.include_hook_events`` is ``True``, the CLI emits
hook lifecycle events (PreToolUse, PostToolUse, Stop, etc.) into the
message stream. Each event is identified by ``hook_event_name`` and the
full raw payload is available in ``data``.

These arrive on the wire as ``{"type": "system", "subtype":
"hook_started" | "hook_response", "hook_event": "PreToolUse", ...}``.

Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and
``case SystemMessage()`` checks continue to match. The base ``subtype``
and ``data`` fields remain populated with the raw payload.

Attributes:
subtype: Lifecycle phase — ``"hook_started"`` when a hook begins
executing, ``"hook_response"`` when it completes (the latter
carries ``output``, ``exit_code``, and ``outcome`` keys in
``data``).
hook_event_name: Name of the hook event (e.g. ``"PreToolUse"``,
``"PostToolUse"``, ``"Stop"``).
data: Full raw event dict from the CLI, including any
event-specific fields not modeled here.
session_id: Session ID the event belongs to, if present.
uuid: Unique ID of the event, if present.
"""

hook_event_name: str = ""
session_id: str | None = None
uuid: str | None = None
Comment thread
claude[bot] marked this conversation as resolved.


Message = (
UserMessage
| AssistantMessage
Expand Down Expand Up @@ -1650,6 +1684,14 @@ class ClaudeAgentOptions:
When true, ``SDKPartialAssistantMessage`` events are emitted during streaming.
"""

include_hook_events: bool = False
"""Include hook lifecycle events in the message stream.

When true, the CLI emits hook events (PreToolUse, PostToolUse, Stop,
etc.) as ``HookEventMessage`` objects in the message stream. Matches the
TypeScript SDK's ``includeHookEvents``.
"""

fork_session: bool = False
"""When true, resumed sessions fork to a new session ID rather than
continuing the previous session. Use with ``resume``."""
Expand Down
61 changes: 61 additions & 0 deletions tests/test_message_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from claude_agent_sdk._internal.message_parser import parse_message
from claude_agent_sdk.types import (
AssistantMessage,
HookEventMessage,
RateLimitEvent,
ResultMessage,
ServerToolResultBlock,
Expand Down Expand Up @@ -964,3 +965,63 @@ def test_parse_result_message_success_no_errors(self):
assert isinstance(message, ResultMessage)
assert message.errors is None
assert message.result == "Task completed successfully"

def test_parse_hook_event_message(self):
"""Hook started events (system/hook_started) parse into HookEventMessage."""
data = {
"type": "system",
"subtype": "hook_started",
"hook_event": "PreToolUse",
"hook_name": "PreToolUse",
"session_id": "sess-123",
"uuid": "uuid-456",
"tool_name": "Bash",
"tool_input": {"command": "ls"},
}
message = parse_message(data)
assert isinstance(message, HookEventMessage)
assert message.subtype == "hook_started"
assert message.hook_event_name == "PreToolUse"
assert message.session_id == "sess-123"
assert message.uuid == "uuid-456"
assert message.data == data

def test_parse_hook_event_message_response(self):
"""Hook response events (system/hook_response) parse into HookEventMessage."""
data = {
"type": "system",
"subtype": "hook_response",
"hook_event": "PostToolUse",
"hook_name": "PostToolUse",
"session_id": "sess-123",
"uuid": "uuid-789",
"output": "",
"exit_code": 0,
"outcome": "success",
}
message = parse_message(data)
assert isinstance(message, HookEventMessage)
assert message.subtype == "hook_response"
assert message.hook_event_name == "PostToolUse"
assert message.session_id == "sess-123"
assert message.uuid == "uuid-789"
assert message.data["output"] == ""
assert message.data["exit_code"] == 0
assert message.data["outcome"] == "success"

def test_parse_hook_event_message_isinstance_system(self):
"""HookEventMessage is a SystemMessage subclass for backward compat."""
data = {"type": "system", "subtype": "hook_started", "hook_event": "PreToolUse"}
message = parse_message(data)
assert isinstance(message, HookEventMessage)
assert isinstance(message, SystemMessage)

def test_parse_hook_event_message_minimal(self):
"""Hook events without session_id/uuid/hook_event still parse."""
data = {"type": "system", "subtype": "hook_started", "hook_name": "Stop"}
message = parse_message(data)
assert isinstance(message, HookEventMessage)
assert message.subtype == "hook_started"
assert message.hook_event_name == "Stop"
assert message.session_id is None
assert message.uuid is None
12 changes: 12 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ def test_build_command_basic(self):
assert "--system-prompt" in cmd
assert cmd[cmd.index("--system-prompt") + 1] == ""

def test_build_command_include_hook_events(self):
"""Test that include_hook_events emits the --include-hook-events flag."""
transport = SubprocessCLITransport(
prompt="Hello", options=make_options(include_hook_events=True)
)
cmd = transport._build_command()
assert "--include-hook-events" in cmd

transport_off = SubprocessCLITransport(prompt="Hello", options=make_options())
cmd_off = transport_off._build_command()
assert "--include-hook-events" not in cmd_off

def test_cli_path_accepts_pathlib_path(self):
"""Test that cli_path accepts pathlib.Path objects."""
from pathlib import Path
Expand Down
Loading