Skip to content
Merged
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
23 changes: 22 additions & 1 deletion src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
46 changes: 42 additions & 4 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
73 changes: 69 additions & 4 deletions tests/test_tool_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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."""
Expand Down