diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 4cb1dea3..d66541f5 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -67,6 +67,7 @@ DeferredToolUse, HookCallback, HookContext, + HookEventMessage, HookInput, HookJSONOutput, HookMatcher, @@ -579,6 +580,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 82305794..91d22cb3 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -8,6 +8,7 @@ AssistantMessage, ContentBlock, DeferredToolUse, + HookEventMessage, Message, MirrorErrorMessage, RateLimitEvent, @@ -49,6 +50,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) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index e786d16b..665e481f 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -334,6 +334,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.strict_mcp_config: cmd.append("--strict-mcp-config") diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 202535df..e9522b55 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1197,6 +1197,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 + + Message = ( UserMessage | AssistantMessage @@ -1707,6 +1741,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 4b24eb55..089a5248 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -7,6 +7,7 @@ from claude_agent_sdk.types import ( AssistantMessage, DeferredToolUse, + HookEventMessage, RateLimitEvent, ResultMessage, ServerToolResultBlock, @@ -989,3 +990,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 diff --git a/tests/test_transport.py b/tests/test_transport.py index 606f5455..62a70b1f 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_build_command_strict_mcp_config(self): """Test that --strict-mcp-config is emitted only when enabled.""" transport = SubprocessCLITransport(