diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 85be30c2..76460100 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -31,6 +31,25 @@ logger = logging.getLogger(__name__) +def _convert_hook_output_for_cli(hook_output: dict[str, Any]) -> dict[str, Any]: + """Convert Python-safe field names to CLI-expected field names. + + The Python SDK uses `async_` and `continue_` to avoid keyword conflicts, + but the CLI expects `async` and `continue`. This function performs the + necessary conversion. + """ + converted = {} + for key, value in hook_output.items(): + # Convert Python-safe names to JavaScript names + if key == "async_": + converted["async"] = value + elif key == "continue_": + converted["continue"] = value + else: + converted[key] = value + return converted + + class Query: """Handles bidirectional control protocol on top of Transport. @@ -244,11 +263,13 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None: if not callback: raise Exception(f"No hook callback found for ID: {callback_id}") - response_data = await callback( + hook_output = await callback( request_data.get("input"), request_data.get("tool_use_id"), {"signal": None}, # TODO: Add abort signal support ) + # Convert Python-safe field names (async_, continue_) to CLI-expected names (async, continue) + response_data = _convert_hook_output_for_cli(hook_output) elif subtype == "mcp_message": # Handle SDK MCP request diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index a1cb210a..3095dfdf 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -198,18 +198,56 @@ class SessionStartHookSpecificOutput(TypedDict): # See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output # for documentation of the output types. +# +# IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid +# Python keyword conflicts. These fields are automatically converted to `async` and +# `continue` when sent to the CLI. You should use the underscore versions in your +# Python code. class AsyncHookJSONOutput(TypedDict): - """Async hook output that defers hook execution.""" + """Async hook output that defers hook execution. - async_: Literal[True] # Using async_ to avoid Python keyword + Fields: + async_: Set to True to defer hook execution. Note: This is converted to + "async" when sent to the CLI - use "async_" in your Python code. + asyncTimeout: Optional timeout in milliseconds for the async operation. + """ + + async_: Literal[ + True + ] # Using async_ to avoid Python keyword (converted to "async" for CLI) asyncTimeout: NotRequired[int] class SyncHookJSONOutput(TypedDict): - """Synchronous hook output with control and decision fields.""" + """Synchronous hook output with control and decision fields. + + This defines the structure for hook callbacks to control execution and provide + feedback to Claude. + + Common Control Fields: + continue_: Whether Claude should proceed after hook execution (default: True). + Note: This is converted to "continue" when sent to the CLI. + suppressOutput: Hide stdout from transcript mode (default: False). + stopReason: Message shown when continue is False. + + Decision Fields: + decision: Set to "block" to indicate blocking behavior. + systemMessage: Warning message displayed to the user. + reason: Feedback message for Claude about the decision. + + Hook-Specific Output: + hookSpecificOutput: Event-specific controls (e.g., permissionDecision for + PreToolUse, additionalContext for PostToolUse). + + Note: The CLI documentation shows field names without underscores ("async", "continue"), + but Python code should use the underscore versions ("async_", "continue_") as they + are automatically converted. + """ # Common control fields - continue_: NotRequired[bool] # Using continue_ to avoid Python keyword + continue_: NotRequired[ + bool + ] # Using continue_ to avoid Python keyword (converted to "continue" for CLI) suppressOutput: NotRequired[bool] stopReason: NotRequired[str] diff --git a/tests/test_tool_callbacks.py b/tests/test_tool_callbacks.py index 789f4204..4987edec 100644 --- a/tests/test_tool_callbacks.py +++ b/tests/test_tool_callbacks.py @@ -322,8 +322,11 @@ async def comprehensive_hook( # The hook result is nested at response.response result = response_data["response"]["response"] - # Verify control fields are present - assert result.get("continue_") is True or result.get("continue") is True + # Verify control fields are present and converted to CLI format + assert result.get("continue") is True, ( + "continue_ should be converted to continue" + ) + assert "continue_" not in result, "continue_ should not appear in CLI output" assert result.get("suppressOutput") is False assert result.get("stopReason") == "Test stop reason" @@ -386,10 +389,72 @@ async def async_hook( # The hook result is nested at response.response result = response_data["response"]["response"] - # The SDK should preserve the async_ field (or convert to "async") - assert result.get("async_") is True or result.get("async") is True + # The SDK should convert async_ to "async" for CLI compatibility + assert result.get("async") is True, "async_ should be converted to async" + assert "async_" not in result, "async_ should not appear in CLI output" assert result.get("asyncTimeout") == 5000 + @pytest.mark.asyncio + async def test_field_name_conversion(self): + """Test that Python-safe field names (async_, continue_) are converted to CLI format (async, continue).""" + + async def conversion_test_hook( + input_data: dict, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + # Return both async_ and continue_ to test conversion + return { + "async_": True, + "asyncTimeout": 10000, + "continue_": False, + "stopReason": "Testing field conversion", + "systemMessage": "Fields should be converted", + } + + transport = MockTransport() + hooks = {"PreToolUse": [{"matcher": None, "hooks": [conversion_test_hook]}]} + + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks + ) + + callback_id = "test_conversion" + query.hook_callbacks[callback_id] = conversion_test_hook + + request = { + "type": "control_request", + "request_id": "test-conversion", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": {"test": "data"}, + "tool_use_id": None, + }, + } + + await query._handle_control_request(request) + + # Check response has converted field names + assert len(transport.written_messages) > 0 + last_response = transport.written_messages[-1] + + response_data = json.loads(last_response) + result = response_data["response"]["response"] + + # Verify async_ was converted to async + assert result.get("async") is True, "async_ should be converted to async" + assert "async_" not in result, "async_ should not appear in output" + + # Verify continue_ was converted to continue + assert result.get("continue") is False, ( + "continue_ should be converted to continue" + ) + assert "continue_" not in result, "continue_ should not appear in output" + + # Verify other fields are unchanged + assert result.get("asyncTimeout") == 10000 + assert result.get("stopReason") == "Testing field conversion" + assert result.get("systemMessage") == "Fields should be converted" + class TestClaudeAgentOptionsIntegration: """Test that callbacks work through ClaudeAgentOptions."""