From e37e059f563125a6ef9be882d79eeaac2ab258ba Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Tue, 5 May 2026 16:32:09 +0000 Subject: [PATCH 1/3] feat: add include_hook_events option Add include_hook_events to ClaudeAgentOptions. When set, the CLI is passed --include-hook-events and hook lifecycle events (PreToolUse, PostToolUse, Stop, etc.) are emitted in the stdout message stream. The SDK parses these into a new HookEventMessage dataclass and yields them from the message stream. Matches the TypeScript SDK's includeHookEvents. --- src/claude_agent_sdk/__init__.py | 2 ++ .../_internal/message_parser.py | 11 +++++++ .../_internal/transport/subprocess_cli.py | 3 ++ src/claude_agent_sdk/types.py | 33 +++++++++++++++++++ tests/test_message_parser.py | 26 +++++++++++++++ tests/test_transport.py | 12 +++++++ 6 files changed, 87 insertions(+) diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 22658d5e..5df9387c 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -66,6 +66,7 @@ ContextUsageResponse, HookCallback, HookContext, + HookEventMessage, HookInput, HookJSONOutput, HookMatcher, @@ -577,6 +578,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: "HookCallback", "HookContext", "HookInput", + "HookEventMessage", "BaseHookInput", "PreToolUseHookInput", "PostToolUseHookInput", diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 757c5ceb..593fe761 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -7,6 +7,7 @@ from ..types import ( AssistantMessage, ContentBlock, + HookEventMessage, Message, MirrorErrorMessage, RateLimitEvent, @@ -48,6 +49,16 @@ def parse_message(data: dict[str, Any]) -> Message | None: data, ) + # Hook events (emitted when ``include_hook_events`` is enabled) carry a + # ``hook_event_name`` discriminator rather than a standard ``type`` field. + if "hook_event_name" in data: + return HookEventMessage( + hook_event_name=data["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) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 9a1d7458..6c666cf2 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -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") diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index c76b5f00..63083fa9 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1152,6 +1152,30 @@ class RateLimitEvent: session_id: str +@dataclass +class HookEventMessage: + """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``. + + Attributes: + 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 + data: dict[str, Any] = field(default_factory=dict) + session_id: str | None = None + uuid: str | None = None + + Message = ( UserMessage | AssistantMessage @@ -1159,6 +1183,7 @@ class RateLimitEvent: | ResultMessage | StreamEvent | RateLimitEvent + | HookEventMessage ) @@ -1650,6 +1675,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``.""" diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 69863bb4..773a26d7 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -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, @@ -964,3 +965,28 @@ 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): + """Test parsing a hook event into HookEventMessage.""" + data = { + "hook_event_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.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_minimal(self): + """Hook events without session_id/uuid still parse.""" + data = {"hook_event_name": "Stop"} + message = parse_message(data) + assert isinstance(message, HookEventMessage) + assert message.hook_event_name == "Stop" + assert message.session_id is None + assert message.uuid is None diff --git a/tests/test_transport.py b/tests/test_transport.py index efe0e2c9..127f5301 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -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 From 6164908b763c3880c848d2f0ca4611aac57b98a8 Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Tue, 5 May 2026 17:50:41 +0000 Subject: [PATCH 2/3] fix: correctly parse hook events from system/hook_started|hook_response messages --- .../_internal/message_parser.py | 20 +++++++++--- src/claude_agent_sdk/types.py | 7 ++++ tests/test_message_parser.py | 32 ++++++++++++++++--- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 593fe761..21d4498b 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -49,11 +49,23 @@ def parse_message(data: dict[str, Any]) -> Message | None: data, ) - # Hook events (emitted when ``include_hook_events`` is enabled) carry a - # ``hook_event_name`` discriminator rather than a standard ``type`` field. - if "hook_event_name" in 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( - hook_event_name=data["hook_event_name"], + subtype=data["subtype"], + hook_event_name=hook_event_name, data=data, session_id=data.get("session_id"), uuid=data.get("uuid"), diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 63083fa9..07424459 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1161,7 +1161,13 @@ class HookEventMessage: 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", ...}``. + Attributes: + subtype: Lifecycle phase — ``"hook_started"`` when a hook begins + executing, ``"hook_response"`` when it completes (the latter + carries a ``response`` key 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 @@ -1170,6 +1176,7 @@ class HookEventMessage: uuid: Unique ID of the event, if present. """ + subtype: str hook_event_name: str data: dict[str, Any] = field(default_factory=dict) session_id: str | None = None diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 773a26d7..337b0ab9 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -967,9 +967,12 @@ def test_parse_result_message_success_no_errors(self): assert message.result == "Task completed successfully" def test_parse_hook_event_message(self): - """Test parsing a hook event into HookEventMessage.""" + """Hook started events (system/hook_started) parse into HookEventMessage.""" data = { - "hook_event_name": "PreToolUse", + "type": "system", + "subtype": "hook_started", + "hook_event": "PreToolUse", + "hook_name": "PreToolUse", "session_id": "sess-123", "uuid": "uuid-456", "tool_name": "Bash", @@ -977,16 +980,37 @@ def test_parse_hook_event_message(self): } 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", + "response": {"decision": "approve"}, + } + 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["response"] == {"decision": "approve"} + def test_parse_hook_event_message_minimal(self): - """Hook events without session_id/uuid still parse.""" - data = {"hook_event_name": "Stop"} + """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 From c996651c8ce7b6c144bb95c694caa4eb5df02055 Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Tue, 5 May 2026 18:12:39 +0000 Subject: [PATCH 3/3] refactor: make HookEventMessage a SystemMessage subclass HookEventMessage arrives on the wire as a system-typed message (type=system, subtype=hook_started|hook_response), matching the established pattern for TaskStartedMessage, TaskProgressMessage, TaskNotificationMessage, and MirrorErrorMessage. Subclassing SystemMessage means existing isinstance(msg, SystemMessage) and case SystemMessage() checks continue to match, and the redundant HookEventMessage entry can be dropped from the Message union. Also fix the docstring to reference the actual hook_response keys (output, exit_code, outcome) instead of a nonexistent response key, and update the test fixture to match the real wire shape. --- src/claude_agent_sdk/types.py | 14 ++++++++------ tests/test_message_parser.py | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 07424459..aa4e321d 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1153,7 +1153,7 @@ class RateLimitEvent: @dataclass -class HookEventMessage: +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 @@ -1164,10 +1164,15 @@ class HookEventMessage: 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 a ``response`` key in ``data``). + 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 @@ -1176,9 +1181,7 @@ class HookEventMessage: uuid: Unique ID of the event, if present. """ - subtype: str - hook_event_name: str - data: dict[str, Any] = field(default_factory=dict) + hook_event_name: str = "" session_id: str | None = None uuid: str | None = None @@ -1190,7 +1193,6 @@ class HookEventMessage: | ResultMessage | StreamEvent | RateLimitEvent - | HookEventMessage ) diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 337b0ab9..e325216c 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -995,7 +995,9 @@ def test_parse_hook_event_message_response(self): "hook_name": "PostToolUse", "session_id": "sess-123", "uuid": "uuid-789", - "response": {"decision": "approve"}, + "output": "", + "exit_code": 0, + "outcome": "success", } message = parse_message(data) assert isinstance(message, HookEventMessage) @@ -1003,7 +1005,16 @@ def test_parse_hook_event_message_response(self): assert message.hook_event_name == "PostToolUse" assert message.session_id == "sess-123" assert message.uuid == "uuid-789" - assert message.data["response"] == {"decision": "approve"} + 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."""