diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index e88971d0..e7de1ba3 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -63,6 +63,7 @@ ContentBlock, ContextUsageCategory, ContextUsageResponse, + DeferredToolUse, HookCallback, HookContext, HookInput, @@ -542,6 +543,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: "TaskNotificationStatus", "TaskUsage", "ResultMessage", + "DeferredToolUse", "RateLimitEvent", "RateLimitInfo", "RateLimitStatus", diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 757c5ceb..82305794 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -7,6 +7,7 @@ from ..types import ( AssistantMessage, ContentBlock, + DeferredToolUse, Message, MirrorErrorMessage, RateLimitEvent, @@ -221,6 +222,7 @@ def parse_message(data: dict[str, Any]) -> Message | None: case "result": try: + deferred = data.get("deferred_tool_use") return ResultMessage( subtype=data["subtype"], duration_ms=data["duration_ms"], @@ -235,6 +237,13 @@ def parse_message(data: dict[str, Any]) -> Message | None: structured_output=data.get("structured_output"), model_usage=data.get("modelUsage"), permission_denials=data.get("permission_denials"), + deferred_tool_use=DeferredToolUse( + id=deferred["id"], + name=deferred["name"], + input=deferred["input"], + ) + if deferred + else None, errors=data.get("errors"), uuid=data.get("uuid"), ) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 83aae5cb..9e04ccda 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -370,7 +370,7 @@ class PreToolUseHookSpecificOutput(TypedDict): """Hook-specific output for PreToolUse events.""" hookEventName: Literal["PreToolUse"] - permissionDecision: NotRequired[Literal["allow", "deny", "ask"]] + permissionDecision: NotRequired[Literal["allow", "deny", "ask", "defer"]] permissionDecisionReason: NotRequired[str] updatedInput: NotRequired[dict[str, Any]] additionalContext: NotRequired[str] @@ -1066,6 +1066,20 @@ class MirrorErrorMessage(SystemMessage): error: str = "" +@dataclass +class DeferredToolUse: + """Tool use that was deferred by a PreToolUse hook returning ``"defer"``. + + When a PreToolUse hook returns ``permissionDecision: "defer"``, the run + stops and the result message carries the deferred tool call here so the + caller can inspect it and decide whether to resume. + """ + + id: str + name: str + input: dict[str, Any] + + @dataclass class ResultMessage: """Result message with cost and usage information.""" @@ -1083,6 +1097,7 @@ class ResultMessage: structured_output: Any = None model_usage: dict[str, Any] | None = None permission_denials: list[Any] | None = None + deferred_tool_use: DeferredToolUse | None = None errors: list[str] | None = None uuid: str | None = None diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 69863bb4..4b24eb55 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -6,6 +6,7 @@ from claude_agent_sdk._internal.message_parser import parse_message from claude_agent_sdk.types import ( AssistantMessage, + DeferredToolUse, RateLimitEvent, ResultMessage, ServerToolResultBlock, @@ -914,9 +915,33 @@ def test_parse_result_message_optional_fields_absent(self): assert isinstance(message, ResultMessage) assert message.model_usage is None assert message.permission_denials is None + assert message.deferred_tool_use is None assert message.errors is None assert message.uuid is None + def test_parse_result_message_with_deferred_tool_use(self): + """ResultMessage parses deferred_tool_use into a DeferredToolUse.""" + data = { + "type": "result", + "subtype": "success", + "duration_ms": 1200, + "duration_api_ms": 900, + "is_error": False, + "num_turns": 1, + "session_id": "session_123", + "deferred_tool_use": { + "id": "toolu_01abc", + "name": "Bash", + "input": {"command": "rm -rf /tmp/scratch"}, + }, + } + message = parse_message(data) + assert isinstance(message, ResultMessage) + assert isinstance(message.deferred_tool_use, DeferredToolUse) + assert message.deferred_tool_use.id == "toolu_01abc" + assert message.deferred_tool_use.name == "Bash" + assert message.deferred_tool_use.input == {"command": "rm -rf /tmp/scratch"} + def test_parse_result_message_with_errors(self): """Test that ResultMessage preserves the errors field from error results.