Skip to content

Commit 48b62a0

Browse files
ashwin-antclaude
andauthored
fix: convert Python-safe field names (async_, continue_) to CLI format (#239)
Fixes critical bug where hook outputs using `async_` and `continue_` (Python-safe names avoiding keyword conflicts) were not being converted to `async` and `continue` as expected by the CLI. This caused hook control fields like `{"decision": "block"}` or `{"continue_": False}` to be silently ignored. Changes: - Add _convert_hook_output_for_cli() to handle field name conversion - Apply conversion in hook callback handling - Update type documentation to clarify field name usage - Add comprehensive test coverage for field name conversion - Update existing tests to verify conversion occurs correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 67e77e9 commit 48b62a0

File tree

3 files changed

+133
-9
lines changed

3 files changed

+133
-9
lines changed

src/claude_agent_sdk/_internal/query.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@
3131
logger = logging.getLogger(__name__)
3232

3333

34+
def _convert_hook_output_for_cli(hook_output: dict[str, Any]) -> dict[str, Any]:
35+
"""Convert Python-safe field names to CLI-expected field names.
36+
37+
The Python SDK uses `async_` and `continue_` to avoid keyword conflicts,
38+
but the CLI expects `async` and `continue`. This function performs the
39+
necessary conversion.
40+
"""
41+
converted = {}
42+
for key, value in hook_output.items():
43+
# Convert Python-safe names to JavaScript names
44+
if key == "async_":
45+
converted["async"] = value
46+
elif key == "continue_":
47+
converted["continue"] = value
48+
else:
49+
converted[key] = value
50+
return converted
51+
52+
3453
class Query:
3554
"""Handles bidirectional control protocol on top of Transport.
3655
@@ -244,11 +263,13 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
244263
if not callback:
245264
raise Exception(f"No hook callback found for ID: {callback_id}")
246265

247-
response_data = await callback(
266+
hook_output = await callback(
248267
request_data.get("input"),
249268
request_data.get("tool_use_id"),
250269
{"signal": None}, # TODO: Add abort signal support
251270
)
271+
# Convert Python-safe field names (async_, continue_) to CLI-expected names (async, continue)
272+
response_data = _convert_hook_output_for_cli(hook_output)
252273

253274
elif subtype == "mcp_message":
254275
# Handle SDK MCP request

src/claude_agent_sdk/types.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,18 +198,56 @@ class SessionStartHookSpecificOutput(TypedDict):
198198

199199
# See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output
200200
# for documentation of the output types.
201+
#
202+
# IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid
203+
# Python keyword conflicts. These fields are automatically converted to `async` and
204+
# `continue` when sent to the CLI. You should use the underscore versions in your
205+
# Python code.
201206
class AsyncHookJSONOutput(TypedDict):
202-
"""Async hook output that defers hook execution."""
207+
"""Async hook output that defers hook execution.
203208
204-
async_: Literal[True] # Using async_ to avoid Python keyword
209+
Fields:
210+
async_: Set to True to defer hook execution. Note: This is converted to
211+
"async" when sent to the CLI - use "async_" in your Python code.
212+
asyncTimeout: Optional timeout in milliseconds for the async operation.
213+
"""
214+
215+
async_: Literal[
216+
True
217+
] # Using async_ to avoid Python keyword (converted to "async" for CLI)
205218
asyncTimeout: NotRequired[int]
206219

207220

208221
class SyncHookJSONOutput(TypedDict):
209-
"""Synchronous hook output with control and decision fields."""
222+
"""Synchronous hook output with control and decision fields.
223+
224+
This defines the structure for hook callbacks to control execution and provide
225+
feedback to Claude.
226+
227+
Common Control Fields:
228+
continue_: Whether Claude should proceed after hook execution (default: True).
229+
Note: This is converted to "continue" when sent to the CLI.
230+
suppressOutput: Hide stdout from transcript mode (default: False).
231+
stopReason: Message shown when continue is False.
232+
233+
Decision Fields:
234+
decision: Set to "block" to indicate blocking behavior.
235+
systemMessage: Warning message displayed to the user.
236+
reason: Feedback message for Claude about the decision.
237+
238+
Hook-Specific Output:
239+
hookSpecificOutput: Event-specific controls (e.g., permissionDecision for
240+
PreToolUse, additionalContext for PostToolUse).
241+
242+
Note: The CLI documentation shows field names without underscores ("async", "continue"),
243+
but Python code should use the underscore versions ("async_", "continue_") as they
244+
are automatically converted.
245+
"""
210246

211247
# Common control fields
212-
continue_: NotRequired[bool] # Using continue_ to avoid Python keyword
248+
continue_: NotRequired[
249+
bool
250+
] # Using continue_ to avoid Python keyword (converted to "continue" for CLI)
213251
suppressOutput: NotRequired[bool]
214252
stopReason: NotRequired[str]
215253

tests/test_tool_callbacks.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,11 @@ async def comprehensive_hook(
322322
# The hook result is nested at response.response
323323
result = response_data["response"]["response"]
324324

325-
# Verify control fields are present
326-
assert result.get("continue_") is True or result.get("continue") is True
325+
# Verify control fields are present and converted to CLI format
326+
assert result.get("continue") is True, (
327+
"continue_ should be converted to continue"
328+
)
329+
assert "continue_" not in result, "continue_ should not appear in CLI output"
327330
assert result.get("suppressOutput") is False
328331
assert result.get("stopReason") == "Test stop reason"
329332

@@ -386,10 +389,72 @@ async def async_hook(
386389
# The hook result is nested at response.response
387390
result = response_data["response"]["response"]
388391

389-
# The SDK should preserve the async_ field (or convert to "async")
390-
assert result.get("async_") is True or result.get("async") is True
392+
# The SDK should convert async_ to "async" for CLI compatibility
393+
assert result.get("async") is True, "async_ should be converted to async"
394+
assert "async_" not in result, "async_ should not appear in CLI output"
391395
assert result.get("asyncTimeout") == 5000
392396

397+
@pytest.mark.asyncio
398+
async def test_field_name_conversion(self):
399+
"""Test that Python-safe field names (async_, continue_) are converted to CLI format (async, continue)."""
400+
401+
async def conversion_test_hook(
402+
input_data: dict, tool_use_id: str | None, context: HookContext
403+
) -> HookJSONOutput:
404+
# Return both async_ and continue_ to test conversion
405+
return {
406+
"async_": True,
407+
"asyncTimeout": 10000,
408+
"continue_": False,
409+
"stopReason": "Testing field conversion",
410+
"systemMessage": "Fields should be converted",
411+
}
412+
413+
transport = MockTransport()
414+
hooks = {"PreToolUse": [{"matcher": None, "hooks": [conversion_test_hook]}]}
415+
416+
query = Query(
417+
transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
418+
)
419+
420+
callback_id = "test_conversion"
421+
query.hook_callbacks[callback_id] = conversion_test_hook
422+
423+
request = {
424+
"type": "control_request",
425+
"request_id": "test-conversion",
426+
"request": {
427+
"subtype": "hook_callback",
428+
"callback_id": callback_id,
429+
"input": {"test": "data"},
430+
"tool_use_id": None,
431+
},
432+
}
433+
434+
await query._handle_control_request(request)
435+
436+
# Check response has converted field names
437+
assert len(transport.written_messages) > 0
438+
last_response = transport.written_messages[-1]
439+
440+
response_data = json.loads(last_response)
441+
result = response_data["response"]["response"]
442+
443+
# Verify async_ was converted to async
444+
assert result.get("async") is True, "async_ should be converted to async"
445+
assert "async_" not in result, "async_ should not appear in output"
446+
447+
# Verify continue_ was converted to continue
448+
assert result.get("continue") is False, (
449+
"continue_ should be converted to continue"
450+
)
451+
assert "continue_" not in result, "continue_ should not appear in output"
452+
453+
# Verify other fields are unchanged
454+
assert result.get("asyncTimeout") == 10000
455+
assert result.get("stopReason") == "Testing field conversion"
456+
assert result.get("systemMessage") == "Fields should be converted"
457+
393458

394459
class TestClaudeAgentOptionsIntegration:
395460
"""Test that callbacks work through ClaudeAgentOptions."""

0 commit comments

Comments
 (0)