Skip to content
Open
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
1 change: 1 addition & 0 deletions src/claude_agent_sdk/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ async def _process_query_inner(
agents=agents_dict,
exclude_dynamic_sections=exclude_dynamic_sections,
skills=configured_options.skills,
include_hook_events=configured_options.include_hook_events,
)

if configured_options.session_store is not None:
Expand Down
8 changes: 5 additions & 3 deletions src/claude_agent_sdk/_internal/message_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ def parse_message(data: dict[str, Any]) -> Message | None:
)

# 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.
# ``system`` messages with ``subtype`` of ``hook_started``,
# ``hook_progress`` 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_progress",
"hook_response",
):
hook_event_name = (
Expand Down
9 changes: 9 additions & 0 deletions src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def __init__(
agents: dict[str, dict[str, Any]] | None = None,
exclude_dynamic_sections: bool | None = None,
skills: list[str] | Literal["all"] | None = None,
include_hook_events: bool = False,
):
"""Initialize Query with transport and callbacks.

Expand All @@ -99,6 +100,10 @@ def __init__(
initialize (see ``SystemPromptPreset``)
skills: Optional skill allowlist to send via initialize so the CLI
can filter which skills are loaded into the system prompt
include_hook_events: When True, the CLI emits hook lifecycle
events (hook_started, hook_progress, hook_response) into the
message stream. Sent as ``includeHookEvents`` in the
initialize request to the CLI.
"""
self._initialize_timeout = initialize_timeout
self.transport = transport
Expand All @@ -109,6 +114,7 @@ def __init__(
self._agents = agents
self._exclude_dynamic_sections = exclude_dynamic_sections
self._skills = skills
self._include_hook_events = include_hook_events

# Control protocol state
self.pending_control_responses: dict[str, anyio.Event] = {}
Expand Down Expand Up @@ -213,6 +219,9 @@ async def initialize(self) -> dict[str, Any] | None:
if isinstance(self._skills, list):
request["skills"] = self._skills

if self._include_hook_events:
request["includeHookEvents"] = True

# Use longer timeout for initialize since MCP servers may take time to start
response = await self._send_control_request(
request, timeout=self._initialize_timeout
Expand Down
10 changes: 6 additions & 4 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1234,17 +1234,19 @@ class HookEventMessage(SystemMessage):
full raw payload is available in ``data``.

These arrive on the wire as ``{"type": "system", "subtype":
"hook_started" | "hook_response", "hook_event": "PreToolUse", ...}``.
"hook_started" | "hook_progress" | "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``).
executing, ``"hook_progress"`` for intermediate progress updates
(matching the TypeScript SDK's lifecycle), ``"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
Expand Down
26 changes: 26 additions & 0 deletions tests/test_message_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,3 +1075,29 @@ def test_parse_hook_event_message_minimal(self):
assert message.hook_event_name == "Stop"
assert message.session_id is None
assert message.uuid is None

def test_parse_hook_progress_message(self):
"""Hook progress events (system/hook_progress) parse into HookEventMessage.

The TypeScript SDK emits hook_progress for intermediate progress
updates during long-running hook execution.
"""
data = {
"type": "system",
"subtype": "hook_progress",
"hook_event": "PreToolUse",
"hook_name": "PreToolUse",
"session_id": "sess-123",
"uuid": "uuid-progress",
"progress_pct": 50,
"message": "Processing...",
}
message = parse_message(data)
assert isinstance(message, HookEventMessage)
assert message.subtype == "hook_progress"
assert message.hook_event_name == "PreToolUse"
assert message.session_id == "sess-123"
assert message.uuid == "uuid-progress"
assert message.data["progress_pct"] == 50
assert message.data["message"] == "Processing..."
assert isinstance(message, SystemMessage)
99 changes: 98 additions & 1 deletion tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ def test_initialize_omits_skills_for_none_and_all():
assert "skills" not in _capture_initialize_request(skills="all")


def test_initialize_sends_include_hook_events():
"""Query.initialize() includes includeHookEvents when the option is True."""
sent = _capture_initialize_request(include_hook_events=True)
assert sent["subtype"] == "initialize"
assert sent["includeHookEvents"] is True


def test_initialize_omits_include_hook_events_when_false():
"""includeHookEvents is absent from initialize when not configured."""
sent = _capture_initialize_request()
assert sent["subtype"] == "initialize"
assert "includeHookEvents" not in sent

sent_false = _capture_initialize_request(include_hook_events=False)
assert "includeHookEvents" not in sent_false


def _make_mock_transport(messages, control_requests=None):
"""Create a mock transport that yields messages and optionally sends control requests.

Expand Down Expand Up @@ -547,7 +564,7 @@ async def _test():


class TestQueryCrossTaskCleanup:
"""Tests for cross-task cleanup of Query task groups (issue #454).
"""Tests for cross-task cleanup of Query task groups (issues #454, #983).

When a user breaks out of an async for loop over process_query(), Python
finalizes the async generator in a different task than the one that called
Expand Down Expand Up @@ -598,6 +615,86 @@ async def _test():

anyio.run(_test)

def test_close_during_generator_teardown_asyncio(self):
"""Regression test for #983: close() called from an async generator's
finally block (triggered by GeneratorExit when the consumer breaks the
loop) must not raise RuntimeError about cross-task cancel scope exit.

This simulates the exact pattern in _process_query_inner where
query.close() is called in a finally block during generator teardown
on a different task than Query.start().
"""
import asyncio

async def _test():
mock_transport = _make_mock_transport(messages=[])
q = Query(transport=mock_transport, is_streaming_mode=True)

await q.start()

errors: list[BaseException] = []

# Wrapping generator that simulates _process_query_inner's
# try/finally pattern: close() runs in the finally block when
# GeneratorExit is thrown by the consumer breaking the loop.
async def wrapping_gen():
try:
async for msg in q.receive_messages():
yield msg
finally:
await q.close()

async def consumer():
try:
async for _ in wrapping_gen():
break # GeneratorExit -> wrapping_gen.finally -> q.close()
except Exception as e:
errors.append(e)

# Run the consumer on a separate task so close() runs on a
# different task than start() — the exact scenario from #983.
task = asyncio.create_task(consumer())
await task

assert errors == [], (
f"close() during generator teardown raised: {errors}"
)

asyncio.run(_test())

def test_close_during_generator_teardown_trio(self):
"""Trio parity for the #983 regression test above."""
async def _test():
mock_transport = _make_mock_transport(messages=[])
q = Query(transport=mock_transport, is_streaming_mode=True)

await q.start()

errors: list[BaseException] = []

async def wrapping_gen():
try:
async for msg in q.receive_messages():
yield msg
finally:
await q.close()

async def consumer():
try:
async for _ in wrapping_gen():
break
except Exception as e:
errors.append(e)

async with anyio.create_task_group() as tg:
tg.start_soon(consumer)

assert errors == [], (
f"close() during generator teardown raised: {errors}"
)

anyio.run(_test, backend="trio")


@pytest.mark.filterwarnings(
"ignore:Unclosed <MemoryObjectReceiveStream:ResourceWarning"
Expand Down