claude-agent-sdk 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of claude-agent-sdk might be problematic. Click here for more details.

Files changed (30) hide show
  1. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/PKG-INFO +1 -1
  2. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/pyproject.toml +1 -1
  3. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/__init__.py +19 -0
  4. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_internal/query.py +31 -4
  5. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_version.py +1 -1
  6. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/types.py +181 -19
  7. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_streaming_client.py +13 -6
  8. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_tool_callbacks.py +201 -2
  9. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_transport.py +23 -12
  10. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/.gitignore +0 -0
  11. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/LICENSE +0 -0
  12. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/README.md +0 -0
  13. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_errors.py +0 -0
  14. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_internal/__init__.py +0 -0
  15. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_internal/client.py +0 -0
  16. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_internal/message_parser.py +0 -0
  17. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_internal/transport/__init__.py +0 -0
  18. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +0 -0
  19. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/client.py +0 -0
  20. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/py.typed +0 -0
  21. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/src/claude_agent_sdk/query.py +0 -0
  22. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/conftest.py +0 -0
  23. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_changelog.py +0 -0
  24. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_client.py +0 -0
  25. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_errors.py +0 -0
  26. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_integration.py +0 -0
  27. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_message_parser.py +0 -0
  28. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_sdk_mcp_integration.py +0 -0
  29. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_subprocess_buffering.py +0 -0
  30. {claude_agent_sdk-0.1.1 → claude_agent_sdk-0.1.3}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-agent-sdk
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Python SDK for Claude Code
5
5
  Project-URL: Homepage, https://github.com/anthropics/claude-agent-sdk-python
6
6
  Project-URL: Documentation, https://docs.anthropic.com/en/docs/claude-code/sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "claude-agent-sdk"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Python SDK for Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -18,11 +18,14 @@ from .query import query
18
18
  from .types import (
19
19
  AgentDefinition,
20
20
  AssistantMessage,
21
+ BaseHookInput,
21
22
  CanUseTool,
22
23
  ClaudeAgentOptions,
23
24
  ContentBlock,
24
25
  HookCallback,
25
26
  HookContext,
27
+ HookInput,
28
+ HookJSONOutput,
26
29
  HookMatcher,
27
30
  McpSdkServerConfig,
28
31
  McpServerConfig,
@@ -32,8 +35,13 @@ from .types import (
32
35
  PermissionResultAllow,
33
36
  PermissionResultDeny,
34
37
  PermissionUpdate,
38
+ PostToolUseHookInput,
39
+ PreCompactHookInput,
40
+ PreToolUseHookInput,
35
41
  ResultMessage,
36
42
  SettingSource,
43
+ StopHookInput,
44
+ SubagentStopHookInput,
37
45
  SystemMessage,
38
46
  TextBlock,
39
47
  ThinkingBlock,
@@ -41,6 +49,7 @@ from .types import (
41
49
  ToolResultBlock,
42
50
  ToolUseBlock,
43
51
  UserMessage,
52
+ UserPromptSubmitHookInput,
44
53
  )
45
54
 
46
55
  # MCP Server Support
@@ -306,8 +315,18 @@ __all__ = [
306
315
  "PermissionResultAllow",
307
316
  "PermissionResultDeny",
308
317
  "PermissionUpdate",
318
+ # Hook support
309
319
  "HookCallback",
310
320
  "HookContext",
321
+ "HookInput",
322
+ "BaseHookInput",
323
+ "PreToolUseHookInput",
324
+ "PostToolUseHookInput",
325
+ "UserPromptSubmitHookInput",
326
+ "StopHookInput",
327
+ "SubagentStopHookInput",
328
+ "PreCompactHookInput",
329
+ "HookJSONOutput",
311
330
  "HookMatcher",
312
331
  # Agent support
313
332
  "AgentDefinition",
@@ -31,6 +31,25 @@ if TYPE_CHECKING:
31
31
  logger = logging.getLogger(__name__)
32
32
 
33
33
 
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
+
34
53
  class Query:
35
54
  """Handles bidirectional control protocol on top of Transport.
36
55
 
@@ -195,6 +214,7 @@ class Query:
195
214
 
196
215
  if subtype == "can_use_tool":
197
216
  permission_request: SDKControlPermissionRequest = request_data # type: ignore[assignment]
217
+ original_input = permission_request["input"]
198
218
  # Handle tool permission request
199
219
  if not self.can_use_tool:
200
220
  raise Exception("canUseTool callback is not provided")
@@ -213,9 +233,14 @@ class Query:
213
233
 
214
234
  # Convert PermissionResult to expected dict format
215
235
  if isinstance(response, PermissionResultAllow):
216
- response_data = {"behavior": "allow"}
217
- if response.updated_input is not None:
218
- response_data["updatedInput"] = response.updated_input
236
+ response_data = {
237
+ "behavior": "allow",
238
+ "updatedInput": (
239
+ response.updated_input
240
+ if response.updated_input is not None
241
+ else original_input
242
+ ),
243
+ }
219
244
  if response.updated_permissions is not None:
220
245
  response_data["updatedPermissions"] = [
221
246
  permission.to_dict()
@@ -238,11 +263,13 @@ class Query:
238
263
  if not callback:
239
264
  raise Exception(f"No hook callback found for ID: {callback_id}")
240
265
 
241
- response_data = await callback(
266
+ hook_output = await callback(
242
267
  request_data.get("input"),
243
268
  request_data.get("tool_use_id"),
244
269
  {"signal": None}, # TODO: Add abort signal support
245
270
  )
271
+ # Convert Python-safe field names (async_, continue_) to CLI-expected names (async, continue)
272
+ response_data = _convert_hook_output_for_cli(hook_output)
246
273
 
247
274
  elif subtype == "mcp_message":
248
275
  # Handle SDK MCP request
@@ -1,3 +1,3 @@
1
1
  """Version information for claude-agent-sdk."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.3"
@@ -157,35 +157,197 @@ HookEvent = (
157
157
  )
158
158
 
159
159
 
160
+ # Hook input types - strongly typed for each hook event
161
+ class BaseHookInput(TypedDict):
162
+ """Base hook input fields present across many hook events."""
163
+
164
+ session_id: str
165
+ transcript_path: str
166
+ cwd: str
167
+ permission_mode: NotRequired[str]
168
+
169
+
170
+ class PreToolUseHookInput(BaseHookInput):
171
+ """Input data for PreToolUse hook events."""
172
+
173
+ hook_event_name: Literal["PreToolUse"]
174
+ tool_name: str
175
+ tool_input: dict[str, Any]
176
+
177
+
178
+ class PostToolUseHookInput(BaseHookInput):
179
+ """Input data for PostToolUse hook events."""
180
+
181
+ hook_event_name: Literal["PostToolUse"]
182
+ tool_name: str
183
+ tool_input: dict[str, Any]
184
+ tool_response: Any
185
+
186
+
187
+ class UserPromptSubmitHookInput(BaseHookInput):
188
+ """Input data for UserPromptSubmit hook events."""
189
+
190
+ hook_event_name: Literal["UserPromptSubmit"]
191
+ prompt: str
192
+
193
+
194
+ class StopHookInput(BaseHookInput):
195
+ """Input data for Stop hook events."""
196
+
197
+ hook_event_name: Literal["Stop"]
198
+ stop_hook_active: bool
199
+
200
+
201
+ class SubagentStopHookInput(BaseHookInput):
202
+ """Input data for SubagentStop hook events."""
203
+
204
+ hook_event_name: Literal["SubagentStop"]
205
+ stop_hook_active: bool
206
+
207
+
208
+ class PreCompactHookInput(BaseHookInput):
209
+ """Input data for PreCompact hook events."""
210
+
211
+ hook_event_name: Literal["PreCompact"]
212
+ trigger: Literal["manual", "auto"]
213
+ custom_instructions: str | None
214
+
215
+
216
+ # Union type for all hook inputs
217
+ HookInput = (
218
+ PreToolUseHookInput
219
+ | PostToolUseHookInput
220
+ | UserPromptSubmitHookInput
221
+ | StopHookInput
222
+ | SubagentStopHookInput
223
+ | PreCompactHookInput
224
+ )
225
+
226
+
227
+ # Hook-specific output types
228
+ class PreToolUseHookSpecificOutput(TypedDict):
229
+ """Hook-specific output for PreToolUse events."""
230
+
231
+ hookEventName: Literal["PreToolUse"]
232
+ permissionDecision: NotRequired[Literal["allow", "deny", "ask"]]
233
+ permissionDecisionReason: NotRequired[str]
234
+ updatedInput: NotRequired[dict[str, Any]]
235
+
236
+
237
+ class PostToolUseHookSpecificOutput(TypedDict):
238
+ """Hook-specific output for PostToolUse events."""
239
+
240
+ hookEventName: Literal["PostToolUse"]
241
+ additionalContext: NotRequired[str]
242
+
243
+
244
+ class UserPromptSubmitHookSpecificOutput(TypedDict):
245
+ """Hook-specific output for UserPromptSubmit events."""
246
+
247
+ hookEventName: Literal["UserPromptSubmit"]
248
+ additionalContext: NotRequired[str]
249
+
250
+
251
+ class SessionStartHookSpecificOutput(TypedDict):
252
+ """Hook-specific output for SessionStart events."""
253
+
254
+ hookEventName: Literal["SessionStart"]
255
+ additionalContext: NotRequired[str]
256
+
257
+
258
+ HookSpecificOutput = (
259
+ PreToolUseHookSpecificOutput
260
+ | PostToolUseHookSpecificOutput
261
+ | UserPromptSubmitHookSpecificOutput
262
+ | SessionStartHookSpecificOutput
263
+ )
264
+
265
+
160
266
  # See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output
161
- # for documentation of the output types. Currently, "continue", "stopReason",
162
- # and "suppressOutput" are not supported in the Python SDK.
163
- class HookJSONOutput(TypedDict):
164
- # Whether to block the action related to the hook.
267
+ # for documentation of the output types.
268
+ #
269
+ # IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid
270
+ # Python keyword conflicts. These fields are automatically converted to `async` and
271
+ # `continue` when sent to the CLI. You should use the underscore versions in your
272
+ # Python code.
273
+ class AsyncHookJSONOutput(TypedDict):
274
+ """Async hook output that defers hook execution.
275
+
276
+ Fields:
277
+ async_: Set to True to defer hook execution. Note: This is converted to
278
+ "async" when sent to the CLI - use "async_" in your Python code.
279
+ asyncTimeout: Optional timeout in milliseconds for the async operation.
280
+ """
281
+
282
+ async_: Literal[
283
+ True
284
+ ] # Using async_ to avoid Python keyword (converted to "async" for CLI)
285
+ asyncTimeout: NotRequired[int]
286
+
287
+
288
+ class SyncHookJSONOutput(TypedDict):
289
+ """Synchronous hook output with control and decision fields.
290
+
291
+ This defines the structure for hook callbacks to control execution and provide
292
+ feedback to Claude.
293
+
294
+ Common Control Fields:
295
+ continue_: Whether Claude should proceed after hook execution (default: True).
296
+ Note: This is converted to "continue" when sent to the CLI.
297
+ suppressOutput: Hide stdout from transcript mode (default: False).
298
+ stopReason: Message shown when continue is False.
299
+
300
+ Decision Fields:
301
+ decision: Set to "block" to indicate blocking behavior.
302
+ systemMessage: Warning message displayed to the user.
303
+ reason: Feedback message for Claude about the decision.
304
+
305
+ Hook-Specific Output:
306
+ hookSpecificOutput: Event-specific controls (e.g., permissionDecision for
307
+ PreToolUse, additionalContext for PostToolUse).
308
+
309
+ Note: The CLI documentation shows field names without underscores ("async", "continue"),
310
+ but Python code should use the underscore versions ("async_", "continue_") as they
311
+ are automatically converted.
312
+ """
313
+
314
+ # Common control fields
315
+ continue_: NotRequired[
316
+ bool
317
+ ] # Using continue_ to avoid Python keyword (converted to "continue" for CLI)
318
+ suppressOutput: NotRequired[bool]
319
+ stopReason: NotRequired[str]
320
+
321
+ # Decision fields
322
+ # Note: "approve" is deprecated for PreToolUse (use permissionDecision instead)
323
+ # For other hooks, only "block" is meaningful
165
324
  decision: NotRequired[Literal["block"]]
166
- # Optionally add a system message that is not visible to Claude but saved in
167
- # the chat transcript.
168
325
  systemMessage: NotRequired[str]
169
- # See each hook's individual "Decision Control" section in the documentation
170
- # for guidance.
171
- hookSpecificOutput: NotRequired[Any]
326
+ reason: NotRequired[str]
172
327
 
328
+ # Hook-specific outputs
329
+ hookSpecificOutput: NotRequired[HookSpecificOutput]
173
330
 
174
- @dataclass
175
- class HookContext:
176
- """Context information for hook callbacks."""
177
331
 
178
- signal: Any | None = None # Future: abort signal support
332
+ HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput
333
+
334
+
335
+ class HookContext(TypedDict):
336
+ """Context information for hook callbacks.
337
+
338
+ Fields:
339
+ signal: Reserved for future abort signal support. Currently always None.
340
+ """
341
+
342
+ signal: Any | None # Future: abort signal support
179
343
 
180
344
 
181
345
  HookCallback = Callable[
182
346
  # HookCallback input parameters:
183
- # - input
184
- # See https://docs.anthropic.com/en/docs/claude-code/hooks#hook-input for
185
- # the type of 'input', the first value.
186
- # - tool_use_id
187
- # - context
188
- [dict[str, Any], str | None, HookContext],
347
+ # - input: Strongly-typed hook input with discriminated unions based on hook_event_name
348
+ # - tool_use_id: Optional tool use identifier
349
+ # - context: Hook context with abort signal support (currently placeholder)
350
+ [HookInput, str | None, HookContext],
189
351
  Awaitable[HookJSONOutput],
190
352
  ]
191
353
 
@@ -633,21 +633,28 @@ assert '"Second"' in stdin_messages[1]
633
633
  print('{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}')
634
634
  """)
635
635
 
636
- Path(test_script).chmod(0o755)
636
+ # Make script executable (Unix-style systems)
637
+ if sys.platform != "win32":
638
+ Path(test_script).chmod(0o755)
637
639
 
638
640
  try:
639
- # Mock _find_cli to return python executing our test script
641
+ # Mock _find_cli to return the test script path directly
640
642
  with patch.object(
641
- SubprocessCLITransport, "_find_cli", return_value=sys.executable
643
+ SubprocessCLITransport, "_find_cli", return_value=test_script
642
644
  ):
643
- # Mock _build_command to add our test script as first argument
645
+ # Mock _build_command to properly execute Python script
644
646
  original_build_command = SubprocessCLITransport._build_command
645
647
 
646
648
  def mock_build_command(self):
647
649
  # Get original command
648
650
  cmd = original_build_command(self)
649
- # Replace the CLI path with python + script
650
- cmd[0] = test_script
651
+ # On Windows, we need to use python interpreter to run the script
652
+ if sys.platform == "win32":
653
+ # Replace first element with python interpreter and script
654
+ cmd[0:1] = [sys.executable, test_script]
655
+ else:
656
+ # On Unix, just use the script directly
657
+ cmd[0] = test_script
651
658
  return cmd
652
659
 
653
660
  with patch.object(
@@ -1,10 +1,14 @@
1
1
  """Tests for tool permission callbacks and hook callbacks."""
2
2
 
3
+ import json
4
+
3
5
  import pytest
4
6
 
5
7
  from claude_agent_sdk import (
6
8
  ClaudeAgentOptions,
7
9
  HookContext,
10
+ HookInput,
11
+ HookJSONOutput,
8
12
  HookMatcher,
9
13
  PermissionResultAllow,
10
14
  PermissionResultDeny,
@@ -213,7 +217,7 @@ class TestHookCallbacks:
213
217
  hook_calls = []
214
218
 
215
219
  async def test_hook(
216
- input_data: dict, tool_use_id: str | None, context: HookContext
220
+ input_data: HookInput, tool_use_id: str | None, context: HookContext
217
221
  ) -> dict:
218
222
  hook_calls.append({"input": input_data, "tool_use_id": tool_use_id})
219
223
  return {"processed": True}
@@ -257,6 +261,201 @@ class TestHookCallbacks:
257
261
  last_response = transport.written_messages[-1]
258
262
  assert '"processed": true' in last_response
259
263
 
264
+ @pytest.mark.asyncio
265
+ async def test_hook_output_fields(self):
266
+ """Test that all SyncHookJSONOutput fields are properly handled."""
267
+
268
+ # Test all SyncHookJSONOutput fields together
269
+ async def comprehensive_hook(
270
+ input_data: HookInput, tool_use_id: str | None, context: HookContext
271
+ ) -> HookJSONOutput:
272
+ return {
273
+ # Control fields
274
+ "continue_": True,
275
+ "suppressOutput": False,
276
+ "stopReason": "Test stop reason",
277
+ # Decision fields
278
+ "decision": "block",
279
+ "systemMessage": "Test system message",
280
+ "reason": "Test reason for blocking",
281
+ # Hook-specific output with all PreToolUse fields
282
+ "hookSpecificOutput": {
283
+ "hookEventName": "PreToolUse",
284
+ "permissionDecision": "deny",
285
+ "permissionDecisionReason": "Security policy violation",
286
+ "updatedInput": {"modified": "input"},
287
+ },
288
+ }
289
+
290
+ transport = MockTransport()
291
+ hooks = {
292
+ "PreToolUse": [
293
+ {"matcher": {"tool": "TestTool"}, "hooks": [comprehensive_hook]}
294
+ ]
295
+ }
296
+
297
+ query = Query(
298
+ transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
299
+ )
300
+
301
+ callback_id = "test_comprehensive_hook"
302
+ query.hook_callbacks[callback_id] = comprehensive_hook
303
+
304
+ request = {
305
+ "type": "control_request",
306
+ "request_id": "test-comprehensive",
307
+ "request": {
308
+ "subtype": "hook_callback",
309
+ "callback_id": callback_id,
310
+ "input": {"test": "data"},
311
+ "tool_use_id": "tool-456",
312
+ },
313
+ }
314
+
315
+ await query._handle_control_request(request)
316
+
317
+ # Check response contains all the fields
318
+ assert len(transport.written_messages) > 0
319
+ last_response = transport.written_messages[-1]
320
+
321
+ # Parse the JSON response
322
+ response_data = json.loads(last_response)
323
+ # The hook result is nested at response.response
324
+ result = response_data["response"]["response"]
325
+
326
+ # Verify control fields are present and converted to CLI format
327
+ assert result.get("continue") is True, (
328
+ "continue_ should be converted to continue"
329
+ )
330
+ assert "continue_" not in result, "continue_ should not appear in CLI output"
331
+ assert result.get("suppressOutput") is False
332
+ assert result.get("stopReason") == "Test stop reason"
333
+
334
+ # Verify decision fields are present
335
+ assert result.get("decision") == "block"
336
+ assert result.get("reason") == "Test reason for blocking"
337
+ assert result.get("systemMessage") == "Test system message"
338
+
339
+ # Verify hook-specific output is present
340
+ hook_output = result.get("hookSpecificOutput", {})
341
+ assert hook_output.get("hookEventName") == "PreToolUse"
342
+ assert hook_output.get("permissionDecision") == "deny"
343
+ assert (
344
+ hook_output.get("permissionDecisionReason") == "Security policy violation"
345
+ )
346
+ assert "updatedInput" in hook_output
347
+
348
+ @pytest.mark.asyncio
349
+ async def test_async_hook_output(self):
350
+ """Test AsyncHookJSONOutput type with proper async fields."""
351
+
352
+ async def async_hook(
353
+ input_data: HookInput, tool_use_id: str | None, context: HookContext
354
+ ) -> HookJSONOutput:
355
+ # Test that async hooks properly use async_ and asyncTimeout fields
356
+ return {
357
+ "async_": True,
358
+ "asyncTimeout": 5000,
359
+ }
360
+
361
+ transport = MockTransport()
362
+ hooks = {"PreToolUse": [{"matcher": None, "hooks": [async_hook]}]}
363
+
364
+ query = Query(
365
+ transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
366
+ )
367
+
368
+ callback_id = "test_async_hook"
369
+ query.hook_callbacks[callback_id] = async_hook
370
+
371
+ request = {
372
+ "type": "control_request",
373
+ "request_id": "test-async",
374
+ "request": {
375
+ "subtype": "hook_callback",
376
+ "callback_id": callback_id,
377
+ "input": {"test": "async_data"},
378
+ "tool_use_id": None,
379
+ },
380
+ }
381
+
382
+ await query._handle_control_request(request)
383
+
384
+ # Check response contains async fields
385
+ assert len(transport.written_messages) > 0
386
+ last_response = transport.written_messages[-1]
387
+
388
+ # Parse the JSON response
389
+ response_data = json.loads(last_response)
390
+ # The hook result is nested at response.response
391
+ result = response_data["response"]["response"]
392
+
393
+ # The SDK should convert async_ to "async" for CLI compatibility
394
+ assert result.get("async") is True, "async_ should be converted to async"
395
+ assert "async_" not in result, "async_ should not appear in CLI output"
396
+ assert result.get("asyncTimeout") == 5000
397
+
398
+ @pytest.mark.asyncio
399
+ async def test_field_name_conversion(self):
400
+ """Test that Python-safe field names (async_, continue_) are converted to CLI format (async, continue)."""
401
+
402
+ async def conversion_test_hook(
403
+ input_data: HookInput, tool_use_id: str | None, context: HookContext
404
+ ) -> HookJSONOutput:
405
+ # Return both async_ and continue_ to test conversion
406
+ return {
407
+ "async_": True,
408
+ "asyncTimeout": 10000,
409
+ "continue_": False,
410
+ "stopReason": "Testing field conversion",
411
+ "systemMessage": "Fields should be converted",
412
+ }
413
+
414
+ transport = MockTransport()
415
+ hooks = {"PreToolUse": [{"matcher": None, "hooks": [conversion_test_hook]}]}
416
+
417
+ query = Query(
418
+ transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
419
+ )
420
+
421
+ callback_id = "test_conversion"
422
+ query.hook_callbacks[callback_id] = conversion_test_hook
423
+
424
+ request = {
425
+ "type": "control_request",
426
+ "request_id": "test-conversion",
427
+ "request": {
428
+ "subtype": "hook_callback",
429
+ "callback_id": callback_id,
430
+ "input": {"test": "data"},
431
+ "tool_use_id": None,
432
+ },
433
+ }
434
+
435
+ await query._handle_control_request(request)
436
+
437
+ # Check response has converted field names
438
+ assert len(transport.written_messages) > 0
439
+ last_response = transport.written_messages[-1]
440
+
441
+ response_data = json.loads(last_response)
442
+ result = response_data["response"]["response"]
443
+
444
+ # Verify async_ was converted to async
445
+ assert result.get("async") is True, "async_ should be converted to async"
446
+ assert "async_" not in result, "async_ should not appear in output"
447
+
448
+ # Verify continue_ was converted to continue
449
+ assert result.get("continue") is False, (
450
+ "continue_ should be converted to continue"
451
+ )
452
+ assert "continue_" not in result, "continue_ should not appear in output"
453
+
454
+ # Verify other fields are unchanged
455
+ assert result.get("asyncTimeout") == 10000
456
+ assert result.get("stopReason") == "Testing field conversion"
457
+ assert result.get("systemMessage") == "Fields should be converted"
458
+
260
459
 
261
460
  class TestClaudeAgentOptionsIntegration:
262
461
  """Test that callbacks work through ClaudeAgentOptions."""
@@ -270,7 +469,7 @@ class TestClaudeAgentOptionsIntegration:
270
469
  return PermissionResultAllow()
271
470
 
272
471
  async def my_hook(
273
- input_data: dict, tool_use_id: str | None, context: HookContext
472
+ input_data: HookInput, tool_use_id: str | None, context: HookContext
274
473
  ) -> dict:
275
474
  return {}
276
475
 
@@ -44,13 +44,15 @@ class TestSubprocessCLITransport:
44
44
  """Test that cli_path accepts pathlib.Path objects."""
45
45
  from pathlib import Path
46
46
 
47
+ path = Path("/usr/bin/claude")
47
48
  transport = SubprocessCLITransport(
48
49
  prompt="Hello",
49
50
  options=ClaudeAgentOptions(),
50
- cli_path=Path("/usr/bin/claude"),
51
+ cli_path=path,
51
52
  )
52
53
 
53
- assert transport._cli_path == "/usr/bin/claude"
54
+ # Path object is converted to string, compare with str(path)
55
+ assert transport._cli_path == str(path)
54
56
 
55
57
  def test_build_command_with_system_prompt_string(self):
56
58
  """Test building CLI command with system prompt as string."""
@@ -129,19 +131,25 @@ class TestSubprocessCLITransport:
129
131
  """Test building CLI command with add_dirs option."""
130
132
  from pathlib import Path
131
133
 
134
+ dir1 = "/path/to/dir1"
135
+ dir2 = Path("/path/to/dir2")
132
136
  transport = SubprocessCLITransport(
133
137
  prompt="test",
134
- options=ClaudeAgentOptions(
135
- add_dirs=["/path/to/dir1", Path("/path/to/dir2")]
136
- ),
138
+ options=ClaudeAgentOptions(add_dirs=[dir1, dir2]),
137
139
  cli_path="/usr/bin/claude",
138
140
  )
139
141
 
140
142
  cmd = transport._build_command()
141
- cmd_str = " ".join(cmd)
142
143
 
143
- # Check that the command string contains the expected --add-dir flags
144
- assert "--add-dir /path/to/dir1 --add-dir /path/to/dir2" in cmd_str
144
+ # Check that both directories are in the command
145
+ assert "--add-dir" in cmd
146
+ add_dir_indices = [i for i, x in enumerate(cmd) if x == "--add-dir"]
147
+ assert len(add_dir_indices) == 2
148
+
149
+ # The directories should appear after --add-dir flags
150
+ dirs_in_cmd = [cmd[i + 1] for i in add_dir_indices]
151
+ assert dir1 in dirs_in_cmd
152
+ assert str(dir2) in dirs_in_cmd
145
153
 
146
154
  def test_session_continuation(self):
147
155
  """Test session continuation options."""
@@ -322,28 +330,31 @@ class TestSubprocessCLITransport:
322
330
  from pathlib import Path
323
331
 
324
332
  # Test with string path
333
+ string_path = "/path/to/mcp-config.json"
325
334
  transport = SubprocessCLITransport(
326
335
  prompt="test",
327
- options=ClaudeAgentOptions(mcp_servers="/path/to/mcp-config.json"),
336
+ options=ClaudeAgentOptions(mcp_servers=string_path),
328
337
  cli_path="/usr/bin/claude",
329
338
  )
330
339
 
331
340
  cmd = transport._build_command()
332
341
  assert "--mcp-config" in cmd
333
342
  mcp_idx = cmd.index("--mcp-config")
334
- assert cmd[mcp_idx + 1] == "/path/to/mcp-config.json"
343
+ assert cmd[mcp_idx + 1] == string_path
335
344
 
336
345
  # Test with Path object
346
+ path_obj = Path("/path/to/mcp-config.json")
337
347
  transport = SubprocessCLITransport(
338
348
  prompt="test",
339
- options=ClaudeAgentOptions(mcp_servers=Path("/path/to/mcp-config.json")),
349
+ options=ClaudeAgentOptions(mcp_servers=path_obj),
340
350
  cli_path="/usr/bin/claude",
341
351
  )
342
352
 
343
353
  cmd = transport._build_command()
344
354
  assert "--mcp-config" in cmd
345
355
  mcp_idx = cmd.index("--mcp-config")
346
- assert cmd[mcp_idx + 1] == "/path/to/mcp-config.json"
356
+ # Path object gets converted to string, compare with str(path_obj)
357
+ assert cmd[mcp_idx + 1] == str(path_obj)
347
358
 
348
359
  def test_build_command_with_mcp_servers_as_json_string(self):
349
360
  """Test building CLI command with mcp_servers as JSON string."""