Skip to content

Commit 62d5ea0

Browse files
committed
Merge origin/main into feat/include-hook-events
2 parents bafb881 + f5a1b67 commit 62d5ea0

11 files changed

Lines changed: 248 additions & 11 deletions

File tree

src/claude_agent_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
ContentBlock,
6565
ContextUsageCategory,
6666
ContextUsageResponse,
67+
DeferredToolUse,
6768
HookCallback,
6869
HookContext,
6970
HookEventMessage,
@@ -545,6 +546,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
545546
"TaskNotificationStatus",
546547
"TaskUsage",
547548
"ResultMessage",
549+
"DeferredToolUse",
548550
"RateLimitEvent",
549551
"RateLimitInfo",
550552
"RateLimitStatus",

src/claude_agent_sdk/_internal/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,4 @@ async def _on_mirror_error(key: Any, error: str) -> None:
227227

228228
finally:
229229
await query.close()
230+
query.close_receive_stream()

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ..types import (
88
AssistantMessage,
99
ContentBlock,
10+
DeferredToolUse,
1011
HookEventMessage,
1112
Message,
1213
MirrorErrorMessage,
@@ -244,6 +245,7 @@ def parse_message(data: dict[str, Any]) -> Message | None:
244245

245246
case "result":
246247
try:
248+
deferred = data.get("deferred_tool_use")
247249
return ResultMessage(
248250
subtype=data["subtype"],
249251
duration_ms=data["duration_ms"],
@@ -258,6 +260,13 @@ def parse_message(data: dict[str, Any]) -> Message | None:
258260
structured_output=data.get("structured_output"),
259261
model_usage=data.get("modelUsage"),
260262
permission_denials=data.get("permission_denials"),
263+
deferred_tool_use=DeferredToolUse(
264+
id=deferred["id"],
265+
name=deferred["name"],
266+
input=deferred["input"],
267+
)
268+
if deferred
269+
else None,
261270
errors=data.get("errors"),
262271
uuid=data.get("uuid"),
263272
)

src/claude_agent_sdk/_internal/query.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
354354
or [],
355355
tool_use_id=permission_request.get("tool_use_id"),
356356
agent_id=permission_request.get("agent_id"),
357+
blocked_path=permission_request.get("blocked_path"),
358+
decision_reason=permission_request.get("decision_reason"),
359+
title=permission_request.get("title"),
360+
display_name=permission_request.get("display_name"),
361+
description=permission_request.get("description"),
357362
)
358363

359364
response = await self.can_use_tool(
@@ -823,10 +828,21 @@ async def close(self) -> None:
823828
# checks _closed before the buffer, so closing it here would make a
824829
# non-parked consumer drop buffered messages with
825830
# ClosedResourceError. _message_send.close() alone yields
826-
# EndOfStream after the buffer drains.
831+
# EndOfStream after the buffer drains; the consumer calls
832+
# close_receive_stream() once it's done iterating (#859).
827833
self._message_send.close()
828834
await self.transport.close()
829835

836+
def close_receive_stream(self) -> None:
837+
"""Close the receive side of the message stream.
838+
839+
Call once the consumer has finished iterating ``receive_messages()``.
840+
``close()`` leaves this open so a still-draining consumer can read
841+
buffered messages; the consumer is responsible for closing it to
842+
avoid a ``ResourceWarning`` from anyio's ``__del__``.
843+
"""
844+
self._message_receive.close()
845+
830846
# Make Query an async iterator
831847
def __aiter__(self) -> AsyncIterator[dict[str, Any]]:
832848
"""Return async iterator for messages."""

src/claude_agent_sdk/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ async def disconnect(self) -> None:
609609
"""Disconnect from Claude."""
610610
if self._query:
611611
await self._query.close()
612+
self._query.close_receive_stream()
612613
self._query = None
613614
self._transport = None
614615
if self._materialized is not None:

src/claude_agent_sdk/types.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class AgentDefinition:
9595
initialPrompt: str | None = None # noqa: N815
9696
maxTurns: int | None = None # noqa: N815
9797
background: bool | None = None
98-
effort: Literal["low", "medium", "high", "max"] | int | None = None
98+
effort: Literal["low", "medium", "high", "xhigh", "max"] | int | None = None
9999
permissionMode: PermissionMode | None = None # noqa: N815
100100

101101

@@ -181,9 +181,29 @@ class ToolPermissionContext:
181181
) # Permission suggestions from CLI
182182
tool_use_id: str | None = None
183183
"""Unique identifier for this specific tool call within the assistant message.
184-
Multiple tool calls in the same assistant message will have different tool_use_ids."""
184+
Multiple tool calls in the same assistant message will have different tool_use_ids.
185+
186+
Always a non-empty string when delivered to a ``can_use_tool`` callback (the
187+
wire protocol guarantees it); the ``Optional`` is only for dataclass
188+
field-ordering compatibility, so callers do not need to handle ``None``."""
185189
agent_id: str | None = None
186190
"""If running within the context of a sub-agent, the sub-agent's ID."""
191+
blocked_path: str | None = None
192+
"""The file path that triggered the permission request, if applicable.
193+
For example, when a Bash command tries to access a path outside allowed directories."""
194+
decision_reason: str | None = None
195+
"""Explains why this permission request was triggered.
196+
When a PreToolUse hook returns ``permissionDecision: "ask"`` with a
197+
``permissionDecisionReason``, that reason is forwarded here."""
198+
title: str | None = None
199+
"""Full permission prompt sentence (e.g. "Claude wants to read foo.txt").
200+
Use this as the primary prompt text when present instead of reconstructing
201+
from tool name + input."""
202+
display_name: str | None = None
203+
"""Short noun phrase for the tool action (e.g. "Read file"), suitable for
204+
button labels or compact UI."""
205+
description: str | None = None
206+
"""Human-readable subtitle for the permission UI."""
187207

188208

189209
# Match TypeScript's PermissionResult structure
@@ -370,7 +390,7 @@ class PreToolUseHookSpecificOutput(TypedDict):
370390
"""Hook-specific output for PreToolUse events."""
371391

372392
hookEventName: Literal["PreToolUse"]
373-
permissionDecision: NotRequired[Literal["allow", "deny", "ask"]]
393+
permissionDecision: NotRequired[Literal["allow", "deny", "ask", "defer"]]
374394
permissionDecisionReason: NotRequired[str]
375395
updatedInput: NotRequired[dict[str, Any]]
376396
additionalContext: NotRequired[str]
@@ -381,7 +401,17 @@ class PostToolUseHookSpecificOutput(TypedDict):
381401

382402
hookEventName: Literal["PostToolUse"]
383403
additionalContext: NotRequired[str]
404+
updatedToolOutput: NotRequired[Any]
405+
"""Replaces the tool output before it is sent to the model.
406+
407+
For built-in tools (Bash, Read, Edit, etc.) the value must match the tool's
408+
output schema (e.g. ``{"stdout": ..., "stderr": ..., "interrupted": ...}``
409+
for Bash); a mismatched shape is rejected and the original output is kept.
410+
"""
384411
updatedMCPToolOutput: NotRequired[Any]
412+
"""Replaces the output for MCP tools only. Prefer ``updatedToolOutput``,
413+
which works for all tools.
414+
"""
385415

386416

387417
class PostToolUseFailureHookSpecificOutput(TypedDict):
@@ -1074,6 +1104,20 @@ class MirrorErrorMessage(SystemMessage):
10741104
error: str = ""
10751105

10761106

1107+
@dataclass
1108+
class DeferredToolUse:
1109+
"""Tool use that was deferred by a PreToolUse hook returning ``"defer"``.
1110+
1111+
When a PreToolUse hook returns ``permissionDecision: "defer"``, the run
1112+
stops and the result message carries the deferred tool call here so the
1113+
caller can inspect it and decide whether to resume.
1114+
"""
1115+
1116+
id: str
1117+
name: str
1118+
input: dict[str, Any]
1119+
1120+
10771121
@dataclass
10781122
class ResultMessage:
10791123
"""Result message with cost and usage information."""
@@ -1091,6 +1135,7 @@ class ResultMessage:
10911135
structured_output: Any = None
10921136
model_usage: dict[str, Any] | None = None
10931137
permission_denials: list[Any] | None = None
1138+
deferred_tool_use: DeferredToolUse | None = None
10941139
errors: list[str] | None = None
10951140
uuid: str | None = None
10961141

@@ -1669,10 +1714,15 @@ class ClaudeAgentOptions:
16691714
"""
16701715

16711716
can_use_tool: CanUseTool | None = None
1672-
"""Custom permission handler for controlling tool usage.
1717+
"""Custom permission handler for tool calls that would otherwise prompt the user.
16731718
1674-
Called before each tool execution to determine if it should be allowed,
1675-
denied, or prompt the user.
1719+
Invoked when the CLI's permission rules evaluate to "ask" for a tool call —
1720+
it is the SDK replacement for the interactive permission prompt. It is *not*
1721+
invoked for tool calls already permitted by ``allowed_tools``,
1722+
``permission_mode`` (e.g. ``"acceptEdits"`` / ``"bypassPermissions"``), or
1723+
``permissions.allow`` rules in settings, since those never reach a prompt.
1724+
To observe or gate *every* tool call regardless of permission rules, use a
1725+
``PreToolUse`` hook via ``hooks`` instead.
16761726
"""
16771727

16781728
hooks: dict[HookEvent, list[HookMatcher]] | None = None
@@ -1783,14 +1833,16 @@ class ClaudeAgentOptions:
17831833
See https://docs.anthropic.com/en/docs/build-with-claude/adaptive-thinking.
17841834
"""
17851835

1786-
effort: Literal["low", "medium", "high", "max"] | None = None
1836+
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None
17871837
"""Controls how much effort Claude puts into its response.
17881838
17891839
Works with adaptive thinking to guide thinking depth.
17901840
17911841
- ``"low"`` — Minimal thinking, fastest responses.
17921842
- ``"medium"`` — Moderate thinking.
17931843
- ``"high"`` — Deep reasoning (default).
1844+
- ``"xhigh"`` — Extended reasoning depth (Opus 4.7 only; falls back to
1845+
``"high"`` on other models).
17941846
- ``"max"`` — Maximum effort.
17951847
17961848
See https://docs.anthropic.com/en/docs/build-with-claude/effort.
@@ -1861,6 +1913,10 @@ class SDKControlPermissionRequest(TypedDict):
18611913
# TODO: Add PermissionUpdate type here
18621914
permission_suggestions: list[Any] | None
18631915
blocked_path: str | None
1916+
decision_reason: NotRequired[str]
1917+
title: NotRequired[str]
1918+
display_name: NotRequired[str]
1919+
description: NotRequired[str]
18641920
tool_use_id: str
18651921
agent_id: NotRequired[str]
18661922

tests/test_client.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ async def _test():
154154
mock_query.start = AsyncMock()
155155
mock_query.initialize = AsyncMock()
156156
mock_query.close = AsyncMock()
157+
mock_query.close_receive_stream = Mock()
157158
mock_query._tg = None
158159

159160
def _consume_coro(coro):
@@ -225,6 +226,7 @@ async def _test():
225226
mock_query.start = AsyncMock()
226227
mock_query.initialize = AsyncMock()
227228
mock_query.close = AsyncMock()
229+
mock_query.close_receive_stream = Mock()
228230
mock_query._tg = None
229231

230232
def _consume_coro(coro):
@@ -258,9 +260,6 @@ async def mock_receive():
258260
anyio.run(_test)
259261

260262

261-
@pytest.mark.filterwarnings(
262-
"ignore:Unclosed <MemoryObjectReceiveStream:ResourceWarning"
263-
)
264263
class TestClaudeSDKClientTrioBackend:
265264
"""Regression test: ClaudeSDKClient must work under trio.
266265
@@ -325,3 +324,43 @@ async def _test():
325324
mock_transport.close.assert_called_once()
326325

327326
anyio.run(_test, backend="trio")
327+
328+
329+
class TestClaudeSDKClientResourceCleanup:
330+
"""Regression for #859: disconnect() must close the receive stream.
331+
332+
``Query.close()`` deliberately leaves ``_message_receive`` open so a
333+
concurrently-draining consumer can finish (see
334+
``test_buffered_messages_drain_after_close_*``). The owning consumer
335+
is responsible for closing it once done; ``disconnect()`` is that
336+
consumer for ``ClaudeSDKClient``. Before the fix, ``__aexit__`` left
337+
the stream open and anyio's ``__del__`` emitted ``ResourceWarning:
338+
Unclosed <MemoryObjectReceiveStream>`` under PYTHONDEVMODE.
339+
"""
340+
341+
def test_disconnect_closes_receive_stream(self):
342+
from anyio import ClosedResourceError
343+
344+
from claude_agent_sdk import ClaudeSDKClient
345+
from claude_agent_sdk._internal.query import Query
346+
347+
async def _test():
348+
mock_transport = AsyncMock()
349+
mock_transport.is_ready = Mock(return_value=True)
350+
351+
async def read_messages():
352+
return
353+
yield
354+
355+
mock_transport.read_messages = read_messages
356+
357+
client = ClaudeSDKClient(transport=mock_transport)
358+
client._transport = mock_transport
359+
client._query = Query(transport=mock_transport, is_streaming_mode=True)
360+
await client._query.start()
361+
receive_stream = client._query._message_receive
362+
await client.disconnect()
363+
with pytest.raises(ClosedResourceError):
364+
receive_stream.receive_nowait()
365+
366+
anyio.run(_test)

tests/test_message_parser.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from claude_agent_sdk._internal.message_parser import parse_message
77
from claude_agent_sdk.types import (
88
AssistantMessage,
9+
DeferredToolUse,
910
HookEventMessage,
1011
RateLimitEvent,
1112
ResultMessage,
@@ -915,9 +916,33 @@ def test_parse_result_message_optional_fields_absent(self):
915916
assert isinstance(message, ResultMessage)
916917
assert message.model_usage is None
917918
assert message.permission_denials is None
919+
assert message.deferred_tool_use is None
918920
assert message.errors is None
919921
assert message.uuid is None
920922

923+
def test_parse_result_message_with_deferred_tool_use(self):
924+
"""ResultMessage parses deferred_tool_use into a DeferredToolUse."""
925+
data = {
926+
"type": "result",
927+
"subtype": "success",
928+
"duration_ms": 1200,
929+
"duration_api_ms": 900,
930+
"is_error": False,
931+
"num_turns": 1,
932+
"session_id": "session_123",
933+
"deferred_tool_use": {
934+
"id": "toolu_01abc",
935+
"name": "Bash",
936+
"input": {"command": "rm -rf /tmp/scratch"},
937+
},
938+
}
939+
message = parse_message(data)
940+
assert isinstance(message, ResultMessage)
941+
assert isinstance(message.deferred_tool_use, DeferredToolUse)
942+
assert message.deferred_tool_use.id == "toolu_01abc"
943+
assert message.deferred_tool_use.name == "Bash"
944+
assert message.deferred_tool_use.input == {"command": "rm -rf /tmp/scratch"}
945+
921946
def test_parse_result_message_with_errors(self):
922947
"""Test that ResultMessage preserves the errors field from error results.
923948

0 commit comments

Comments
 (0)