Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -67,6 +67,7 @@
DeferredToolUse,
HookCallback,
HookContext,
HookEventMessage,
HookInput,
HookJSONOutput,
HookMatcher,
Expand Down Expand Up @@ -579,6 +580,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 @@ -8,6 +8,7 @@
AssistantMessage,
ContentBlock,
DeferredToolUse,
HookEventMessage,
Message,
MirrorErrorMessage,
RateLimitEvent,
Expand Down Expand Up @@ -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)
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 @@ -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")

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 @@ -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
Comment thread
claude[bot] marked this conversation as resolved.


Message = (
UserMessage
| AssistantMessage
Expand Down Expand Up @@ -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``."""
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 @@ -7,6 +7,7 @@
from claude_agent_sdk.types import (
AssistantMessage,
DeferredToolUse,
HookEventMessage,
RateLimitEvent,
ResultMessage,
ServerToolResultBlock,
Expand Down Expand Up @@ -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
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_build_command_strict_mcp_config(self):
"""Test that --strict-mcp-config is emitted only when enabled."""
transport = SubprocessCLITransport(
Expand Down
Loading