From b4606a9ac461fa4f9d39a5cac430376f3c865ad3 Mon Sep 17 00:00:00 2001 From: Autumn Date: Mon, 1 Jun 2026 02:09:31 +0800 Subject: [PATCH] fix: include hook lifecycle events in message stream when include_hook_events is enabled Bug: When include_hook_events=True in ClaudeAgentOptions, the --include-hook-events CLI flag was passed correctly and hook callbacks executed, but hook_started and hook_response system messages never appeared in the parsed message stream. Root cause: The CLI needs send includeHookEvents: true in the initialize request (control protocol) to emit hook lifecycle messages to stdout. The SDK only passed --include-hook-events as a CLI flag but never told the CLI via the control protocol. Changes: - Add include_hook_events parameter to Query.__init__() and send it as includeHookEvents: true in the initialize request payload - Pass include_hook_events from InternalClient to Query - Add hook_progress to the recognized system message subtypes in the message parser (matching TypeScript SDK's hook_progress lifecycle) - Update HookEventMessage docstring to document hook_progress - Add tests for hook_progress parsing and includeHookEvents in initialize Closes #991 Co-Authored-By: Claude Opus 4.8 --- src/claude_agent_sdk/_internal/client.py | 1 + .../_internal/message_parser.py | 8 +- src/claude_agent_sdk/_internal/query.py | 9 ++ src/claude_agent_sdk/types.py | 10 +- tests/test_message_parser.py | 26 +++++ tests/test_query.py | 99 ++++++++++++++++++- 6 files changed, 145 insertions(+), 8 deletions(-) diff --git a/src/claude_agent_sdk/_internal/client.py b/src/claude_agent_sdk/_internal/client.py index 010025b9..035a09b5 100644 --- a/src/claude_agent_sdk/_internal/client.py +++ b/src/claude_agent_sdk/_internal/client.py @@ -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: diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 574816c6..b390da11 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -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 = ( diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 7a4f8a44..809b7b24 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -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. @@ -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 @@ -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] = {} @@ -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 diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index ee925b35..8ea86b8f 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1234,7 +1234,8 @@ 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`` @@ -1242,9 +1243,10 @@ class HookEventMessage(SystemMessage): 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 diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 7ce2990c..4b21c1b0 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -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) diff --git a/tests/test_query.py b/tests/test_query.py index 16c088b1..7dc74968 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -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. @@ -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 @@ -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