claude-agent-sdk 0.1.0__tar.gz → 0.1.2__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.0 → claude_agent_sdk-0.1.2}/PKG-INFO +13 -4
  2. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/README.md +12 -3
  3. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/pyproject.toml +1 -1
  4. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/__init__.py +2 -0
  5. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_internal/query.py +17 -6
  6. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +45 -0
  7. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_version.py +1 -1
  8. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/client.py +18 -8
  9. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/types.py +101 -9
  10. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_streaming_client.py +13 -6
  11. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_tool_callbacks.py +137 -4
  12. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_transport.py +77 -27
  13. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_types.py +2 -2
  14. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/.gitignore +0 -0
  15. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/LICENSE +0 -0
  16. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_errors.py +0 -0
  17. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_internal/__init__.py +0 -0
  18. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_internal/client.py +0 -0
  19. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_internal/message_parser.py +0 -0
  20. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/_internal/transport/__init__.py +0 -0
  21. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/py.typed +0 -0
  22. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/src/claude_agent_sdk/query.py +0 -0
  23. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/conftest.py +0 -0
  24. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_changelog.py +0 -0
  25. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_client.py +0 -0
  26. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_errors.py +0 -0
  27. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_integration.py +0 -0
  28. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_message_parser.py +0 -0
  29. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_sdk_mcp_integration.py +0 -0
  30. {claude_agent_sdk-0.1.0 → claude_agent_sdk-0.1.2}/tests/test_subprocess_buffering.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-agent-sdk
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -43,8 +43,8 @@ pip install claude-agent-sdk
43
43
 
44
44
  **Prerequisites:**
45
45
  - Python 3.10+
46
- - Node.js
47
- - Claude Code: `npm install -g @anthropic-ai/claude-code`
46
+ - Node.js
47
+ - Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code`
48
48
 
49
49
  ## Quick Start
50
50
 
@@ -92,7 +92,7 @@ options = ClaudeAgentOptions(
92
92
  )
93
93
 
94
94
  async for message in query(
95
- prompt="Create a hello.py file",
95
+ prompt="Create a hello.py file",
96
96
  options=options
97
97
  ):
98
98
  # Process tool use and results
@@ -304,6 +304,15 @@ See [examples/quick_start.py](examples/quick_start.py) for a complete working ex
304
304
 
305
305
  See [examples/streaming_mode.py](examples/streaming_mode.py) for comprehensive examples involving `ClaudeSDKClient`. You can even run interactive examples in IPython from [examples/streaming_mode_ipython.py](examples/streaming_mode_ipython.py).
306
306
 
307
+ ## Migrating from Claude Code SDK
308
+
309
+ If you're upgrading from the Claude Code SDK (versions < 0.1.0), please see the [CHANGELOG.md](CHANGELOG.md#010) for details on breaking changes and new features, including:
310
+
311
+ - `ClaudeCodeOptions` → `ClaudeAgentOptions` rename
312
+ - Merged system prompt configuration
313
+ - Settings isolation and explicit control
314
+ - New programmatic subagents and session forking features
315
+
307
316
  ## License
308
317
 
309
318
  MIT
@@ -10,8 +10,8 @@ pip install claude-agent-sdk
10
10
 
11
11
  **Prerequisites:**
12
12
  - Python 3.10+
13
- - Node.js
14
- - Claude Code: `npm install -g @anthropic-ai/claude-code`
13
+ - Node.js
14
+ - Claude Code 2.0.0+: `npm install -g @anthropic-ai/claude-code`
15
15
 
16
16
  ## Quick Start
17
17
 
@@ -59,7 +59,7 @@ options = ClaudeAgentOptions(
59
59
  )
60
60
 
61
61
  async for message in query(
62
- prompt="Create a hello.py file",
62
+ prompt="Create a hello.py file",
63
63
  options=options
64
64
  ):
65
65
  # Process tool use and results
@@ -271,6 +271,15 @@ See [examples/quick_start.py](examples/quick_start.py) for a complete working ex
271
271
 
272
272
  See [examples/streaming_mode.py](examples/streaming_mode.py) for comprehensive examples involving `ClaudeSDKClient`. You can even run interactive examples in IPython from [examples/streaming_mode_ipython.py](examples/streaming_mode_ipython.py).
273
273
 
274
+ ## Migrating from Claude Code SDK
275
+
276
+ If you're upgrading from the Claude Code SDK (versions < 0.1.0), please see the [CHANGELOG.md](CHANGELOG.md#010) for details on breaking changes and new features, including:
277
+
278
+ - `ClaudeCodeOptions` → `ClaudeAgentOptions` rename
279
+ - Merged system prompt configuration
280
+ - Settings isolation and explicit control
281
+ - New programmatic subagents and session forking features
282
+
274
283
  ## License
275
284
 
276
285
  MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "claude-agent-sdk"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Python SDK for Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -23,6 +23,7 @@ from .types import (
23
23
  ContentBlock,
24
24
  HookCallback,
25
25
  HookContext,
26
+ HookJSONOutput,
26
27
  HookMatcher,
27
28
  McpSdkServerConfig,
28
29
  McpServerConfig,
@@ -308,6 +309,7 @@ __all__ = [
308
309
  "PermissionUpdate",
309
310
  "HookCallback",
310
311
  "HookContext",
312
+ "HookJSONOutput",
311
313
  "HookMatcher",
312
314
  # Agent support
313
315
  "AgentDefinition",
@@ -195,6 +195,7 @@ class Query:
195
195
 
196
196
  if subtype == "can_use_tool":
197
197
  permission_request: SDKControlPermissionRequest = request_data # type: ignore[assignment]
198
+ original_input = permission_request["input"]
198
199
  # Handle tool permission request
199
200
  if not self.can_use_tool:
200
201
  raise Exception("canUseTool callback is not provided")
@@ -213,13 +214,23 @@ class Query:
213
214
 
214
215
  # Convert PermissionResult to expected dict format
215
216
  if isinstance(response, PermissionResultAllow):
216
- response_data = {"allow": True}
217
- if response.updated_input is not None:
218
- response_data["input"] = response.updated_input
219
- # TODO: Handle updatedPermissions when control protocol supports it
217
+ response_data = {
218
+ "behavior": "allow",
219
+ "updatedInput": (
220
+ response.updated_input
221
+ if response.updated_input is not None
222
+ else original_input
223
+ ),
224
+ }
225
+ if response.updated_permissions is not None:
226
+ response_data["updatedPermissions"] = [
227
+ permission.to_dict()
228
+ for permission in response.updated_permissions
229
+ ]
220
230
  elif isinstance(response, PermissionResultDeny):
221
- response_data = {"allow": False, "reason": response.message}
222
- # TODO: Handle interrupt flag when control protocol supports it
231
+ response_data = {"behavior": "deny", "message": response.message}
232
+ if response.interrupt:
233
+ response_data["interrupt"] = response.interrupt
223
234
  else:
224
235
  raise TypeError(
225
236
  f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}"
@@ -3,7 +3,9 @@
3
3
  import json
4
4
  import logging
5
5
  import os
6
+ import re
6
7
  import shutil
8
+ import sys
7
9
  from collections.abc import AsyncIterable, AsyncIterator
8
10
  from contextlib import suppress
9
11
  from dataclasses import asdict
@@ -25,6 +27,7 @@ from . import Transport
25
27
  logger = logging.getLogger(__name__)
26
28
 
27
29
  _DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
30
+ MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"
28
31
 
29
32
 
30
33
  class SubprocessCLITransport(Transport):
@@ -202,6 +205,8 @@ class SubprocessCLITransport(Transport):
202
205
  if self._process:
203
206
  return
204
207
 
208
+ await self._check_claude_version()
209
+
205
210
  cmd = self._build_command()
206
211
  try:
207
212
  # Merge environment variables: system -> user -> SDK required
@@ -448,6 +453,46 @@ class SubprocessCLITransport(Transport):
448
453
  )
449
454
  raise self._exit_error
450
455
 
456
+ async def _check_claude_version(self) -> None:
457
+ """Check Claude Code version and warn if below minimum."""
458
+ version_process = None
459
+ try:
460
+ with anyio.fail_after(2): # 2 second timeout
461
+ version_process = await anyio.open_process(
462
+ [self._cli_path, "-v"],
463
+ stdout=PIPE,
464
+ stderr=PIPE,
465
+ )
466
+
467
+ if version_process.stdout:
468
+ stdout_bytes = await version_process.stdout.receive()
469
+ version_output = stdout_bytes.decode().strip()
470
+
471
+ match = re.match(r"([0-9]+\.[0-9]+\.[0-9]+)", version_output)
472
+ if match:
473
+ version = match.group(1)
474
+ version_parts = [int(x) for x in version.split(".")]
475
+ min_parts = [
476
+ int(x) for x in MINIMUM_CLAUDE_CODE_VERSION.split(".")
477
+ ]
478
+
479
+ if version_parts < min_parts:
480
+ warning = (
481
+ f"Warning: Claude Code version {version} is unsupported in the Agent SDK. "
482
+ f"Minimum required version is {MINIMUM_CLAUDE_CODE_VERSION}. "
483
+ "Some features may not work correctly."
484
+ )
485
+ logger.warning(warning)
486
+ print(warning, file=sys.stderr)
487
+ except Exception:
488
+ pass
489
+ finally:
490
+ if version_process:
491
+ with suppress(Exception):
492
+ version_process.terminate()
493
+ with suppress(Exception):
494
+ await version_process.wait()
495
+
451
496
  def is_ready(self) -> bool:
452
497
  """Check if transport is ready for communication."""
453
498
  return self._ready
@@ -1,3 +1,3 @@
1
1
  """Version information for claude-agent-sdk."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -6,6 +6,7 @@ from collections.abc import AsyncIterable, AsyncIterator
6
6
  from dataclasses import replace
7
7
  from typing import Any
8
8
 
9
+ from . import Transport
9
10
  from ._errors import CLIConnectionError
10
11
  from .types import ClaudeAgentOptions, HookEvent, HookMatcher, Message, ResultMessage
11
12
 
@@ -51,12 +52,17 @@ class ClaudeSDKClient:
51
52
  exist.
52
53
  """
53
54
 
54
- def __init__(self, options: ClaudeAgentOptions | None = None):
55
+ def __init__(
56
+ self,
57
+ options: ClaudeAgentOptions | None = None,
58
+ transport: Transport | None = None,
59
+ ):
55
60
  """Initialize Claude SDK client."""
56
61
  if options is None:
57
62
  options = ClaudeAgentOptions()
58
63
  self.options = options
59
- self._transport: Any | None = None
64
+ self._custom_transport = transport
65
+ self._transport: Transport | None = None
60
66
  self._query: Any | None = None
61
67
  os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client"
62
68
 
@@ -115,10 +121,14 @@ class ClaudeSDKClient:
115
121
  else:
116
122
  options = self.options
117
123
 
118
- self._transport = SubprocessCLITransport(
119
- prompt=actual_prompt,
120
- options=options,
121
- )
124
+ # Use provided custom transport or create subprocess transport
125
+ if self._custom_transport:
126
+ self._transport = self._custom_transport
127
+ else:
128
+ self._transport = SubprocessCLITransport(
129
+ prompt=actual_prompt,
130
+ options=options,
131
+ )
122
132
  await self._transport.connect()
123
133
 
124
134
  # Extract SDK MCP servers from options
@@ -222,7 +232,7 @@ class ClaudeSDKClient:
222
232
 
223
233
  Args:
224
234
  model: The model to use, or None to use default. Examples:
225
- - 'claude-sonnet-4-20250514'
235
+ - 'claude-sonnet-4-5'
226
236
  - 'claude-opus-4-1-20250805'
227
237
  - 'claude-opus-4-20250514'
228
238
 
@@ -233,7 +243,7 @@ class ClaudeSDKClient:
233
243
  await client.query("Help me understand this problem")
234
244
 
235
245
  # Switch to a different model for implementation
236
- await client.set_model('claude-3-5-sonnet-20241022')
246
+ await client.set_model('claude-sonnet-4-5')
237
247
  await client.query("Now implement the solution")
238
248
  ```
239
249
  """
@@ -70,6 +70,42 @@ class PermissionUpdate:
70
70
  directories: list[str] | None = None
71
71
  destination: PermissionUpdateDestination | None = None
72
72
 
73
+ def to_dict(self) -> dict[str, Any]:
74
+ """Convert PermissionUpdate to dictionary format matching TypeScript control protocol."""
75
+ result: dict[str, Any] = {
76
+ "type": self.type,
77
+ }
78
+
79
+ # Add destination for all variants
80
+ if self.destination is not None:
81
+ result["destination"] = self.destination
82
+
83
+ # Handle different type variants
84
+ if self.type in ["addRules", "replaceRules", "removeRules"]:
85
+ # Rules-based variants require rules and behavior
86
+ if self.rules is not None:
87
+ result["rules"] = [
88
+ {
89
+ "toolName": rule.tool_name,
90
+ "ruleContent": rule.rule_content,
91
+ }
92
+ for rule in self.rules
93
+ ]
94
+ if self.behavior is not None:
95
+ result["behavior"] = self.behavior
96
+
97
+ elif self.type == "setMode":
98
+ # Mode variant requires mode
99
+ if self.mode is not None:
100
+ result["mode"] = self.mode
101
+
102
+ elif self.type in ["addDirectories", "removeDirectories"]:
103
+ # Directory variants require directories
104
+ if self.directories is not None:
105
+ result["directories"] = self.directories
106
+
107
+ return result
108
+
73
109
 
74
110
  # Tool callback types
75
111
  @dataclass
@@ -121,18 +157,74 @@ HookEvent = (
121
157
  )
122
158
 
123
159
 
160
+ # Hook-specific output types
161
+ class PreToolUseHookSpecificOutput(TypedDict):
162
+ """Hook-specific output for PreToolUse events."""
163
+
164
+ hookEventName: Literal["PreToolUse"]
165
+ permissionDecision: NotRequired[Literal["allow", "deny", "ask"]]
166
+ permissionDecisionReason: NotRequired[str]
167
+ updatedInput: NotRequired[dict[str, Any]]
168
+
169
+
170
+ class PostToolUseHookSpecificOutput(TypedDict):
171
+ """Hook-specific output for PostToolUse events."""
172
+
173
+ hookEventName: Literal["PostToolUse"]
174
+ additionalContext: NotRequired[str]
175
+
176
+
177
+ class UserPromptSubmitHookSpecificOutput(TypedDict):
178
+ """Hook-specific output for UserPromptSubmit events."""
179
+
180
+ hookEventName: Literal["UserPromptSubmit"]
181
+ additionalContext: NotRequired[str]
182
+
183
+
184
+ class SessionStartHookSpecificOutput(TypedDict):
185
+ """Hook-specific output for SessionStart events."""
186
+
187
+ hookEventName: Literal["SessionStart"]
188
+ additionalContext: NotRequired[str]
189
+
190
+
191
+ HookSpecificOutput = (
192
+ PreToolUseHookSpecificOutput
193
+ | PostToolUseHookSpecificOutput
194
+ | UserPromptSubmitHookSpecificOutput
195
+ | SessionStartHookSpecificOutput
196
+ )
197
+
198
+
124
199
  # See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output
125
- # for documentation of the output types. Currently, "continue", "stopReason",
126
- # and "suppressOutput" are not supported in the Python SDK.
127
- class HookJSONOutput(TypedDict):
128
- # Whether to block the action related to the hook.
200
+ # for documentation of the output types.
201
+ class AsyncHookJSONOutput(TypedDict):
202
+ """Async hook output that defers hook execution."""
203
+
204
+ async_: Literal[True] # Using async_ to avoid Python keyword
205
+ asyncTimeout: NotRequired[int]
206
+
207
+
208
+ class SyncHookJSONOutput(TypedDict):
209
+ """Synchronous hook output with control and decision fields."""
210
+
211
+ # Common control fields
212
+ continue_: NotRequired[bool] # Using continue_ to avoid Python keyword
213
+ suppressOutput: NotRequired[bool]
214
+ stopReason: NotRequired[str]
215
+
216
+ # Decision fields
217
+ # Note: "approve" is deprecated for PreToolUse (use permissionDecision instead)
218
+ # For other hooks, only "block" is meaningful
129
219
  decision: NotRequired[Literal["block"]]
130
- # Optionally add a system message that is not visible to Claude but saved in
131
- # the chat transcript.
132
220
  systemMessage: NotRequired[str]
133
- # See each hook's individual "Decision Control" section in the documentation
134
- # for guidance.
135
- hookSpecificOutput: NotRequired[Any]
221
+ reason: NotRequired[str]
222
+
223
+ # Hook-specific outputs
224
+ hookSpecificOutput: NotRequired[HookSpecificOutput]
225
+
226
+
227
+ HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput
136
228
 
137
229
 
138
230
  @dataclass
@@ -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,13 @@
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
+ HookJSONOutput,
8
11
  HookMatcher,
9
12
  PermissionResultAllow,
10
13
  PermissionResultDeny,
@@ -90,7 +93,7 @@ class TestToolPermissionCallbacks:
90
93
  # Check response was sent
91
94
  assert len(transport.written_messages) == 1
92
95
  response = transport.written_messages[0]
93
- assert '"allow": true' in response
96
+ assert '"behavior": "allow"' in response
94
97
 
95
98
  @pytest.mark.asyncio
96
99
  async def test_permission_callback_deny(self):
@@ -125,8 +128,8 @@ class TestToolPermissionCallbacks:
125
128
  # Check response
126
129
  assert len(transport.written_messages) == 1
127
130
  response = transport.written_messages[0]
128
- assert '"allow": false' in response
129
- assert '"reason": "Security policy violation"' in response
131
+ assert '"behavior": "deny"' in response
132
+ assert '"message": "Security policy violation"' in response
130
133
 
131
134
  @pytest.mark.asyncio
132
135
  async def test_permission_callback_input_modification(self):
@@ -164,7 +167,7 @@ class TestToolPermissionCallbacks:
164
167
  # Check response includes modified input
165
168
  assert len(transport.written_messages) == 1
166
169
  response = transport.written_messages[0]
167
- assert '"allow": true' in response
170
+ assert '"behavior": "allow"' in response
168
171
  assert '"safe_mode": true' in response
169
172
 
170
173
  @pytest.mark.asyncio
@@ -257,6 +260,136 @@ class TestHookCallbacks:
257
260
  last_response = transport.written_messages[-1]
258
261
  assert '"processed": true' in last_response
259
262
 
263
+ @pytest.mark.asyncio
264
+ async def test_hook_output_fields(self):
265
+ """Test that all SyncHookJSONOutput fields are properly handled."""
266
+
267
+ # Test all SyncHookJSONOutput fields together
268
+ async def comprehensive_hook(
269
+ input_data: dict, tool_use_id: str | None, context: HookContext
270
+ ) -> HookJSONOutput:
271
+ return {
272
+ # Control fields
273
+ "continue_": True,
274
+ "suppressOutput": False,
275
+ "stopReason": "Test stop reason",
276
+ # Decision fields
277
+ "decision": "block",
278
+ "systemMessage": "Test system message",
279
+ "reason": "Test reason for blocking",
280
+ # Hook-specific output with all PreToolUse fields
281
+ "hookSpecificOutput": {
282
+ "hookEventName": "PreToolUse",
283
+ "permissionDecision": "deny",
284
+ "permissionDecisionReason": "Security policy violation",
285
+ "updatedInput": {"modified": "input"},
286
+ },
287
+ }
288
+
289
+ transport = MockTransport()
290
+ hooks = {
291
+ "PreToolUse": [
292
+ {"matcher": {"tool": "TestTool"}, "hooks": [comprehensive_hook]}
293
+ ]
294
+ }
295
+
296
+ query = Query(
297
+ transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
298
+ )
299
+
300
+ callback_id = "test_comprehensive_hook"
301
+ query.hook_callbacks[callback_id] = comprehensive_hook
302
+
303
+ request = {
304
+ "type": "control_request",
305
+ "request_id": "test-comprehensive",
306
+ "request": {
307
+ "subtype": "hook_callback",
308
+ "callback_id": callback_id,
309
+ "input": {"test": "data"},
310
+ "tool_use_id": "tool-456",
311
+ },
312
+ }
313
+
314
+ await query._handle_control_request(request)
315
+
316
+ # Check response contains all the fields
317
+ assert len(transport.written_messages) > 0
318
+ last_response = transport.written_messages[-1]
319
+
320
+ # Parse the JSON response
321
+ response_data = json.loads(last_response)
322
+ # The hook result is nested at response.response
323
+ result = response_data["response"]["response"]
324
+
325
+ # Verify control fields are present
326
+ assert result.get("continue_") is True or result.get("continue") is True
327
+ assert result.get("suppressOutput") is False
328
+ assert result.get("stopReason") == "Test stop reason"
329
+
330
+ # Verify decision fields are present
331
+ assert result.get("decision") == "block"
332
+ assert result.get("reason") == "Test reason for blocking"
333
+ assert result.get("systemMessage") == "Test system message"
334
+
335
+ # Verify hook-specific output is present
336
+ hook_output = result.get("hookSpecificOutput", {})
337
+ assert hook_output.get("hookEventName") == "PreToolUse"
338
+ assert hook_output.get("permissionDecision") == "deny"
339
+ assert (
340
+ hook_output.get("permissionDecisionReason") == "Security policy violation"
341
+ )
342
+ assert "updatedInput" in hook_output
343
+
344
+ @pytest.mark.asyncio
345
+ async def test_async_hook_output(self):
346
+ """Test AsyncHookJSONOutput type with proper async fields."""
347
+
348
+ async def async_hook(
349
+ input_data: dict, tool_use_id: str | None, context: HookContext
350
+ ) -> HookJSONOutput:
351
+ # Test that async hooks properly use async_ and asyncTimeout fields
352
+ return {
353
+ "async_": True,
354
+ "asyncTimeout": 5000,
355
+ }
356
+
357
+ transport = MockTransport()
358
+ hooks = {"PreToolUse": [{"matcher": None, "hooks": [async_hook]}]}
359
+
360
+ query = Query(
361
+ transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks
362
+ )
363
+
364
+ callback_id = "test_async_hook"
365
+ query.hook_callbacks[callback_id] = async_hook
366
+
367
+ request = {
368
+ "type": "control_request",
369
+ "request_id": "test-async",
370
+ "request": {
371
+ "subtype": "hook_callback",
372
+ "callback_id": callback_id,
373
+ "input": {"test": "async_data"},
374
+ "tool_use_id": None,
375
+ },
376
+ }
377
+
378
+ await query._handle_control_request(request)
379
+
380
+ # Check response contains async fields
381
+ assert len(transport.written_messages) > 0
382
+ last_response = transport.written_messages[-1]
383
+
384
+ # Parse the JSON response
385
+ response_data = json.loads(last_response)
386
+ # The hook result is nested at response.response
387
+ result = response_data["response"]["response"]
388
+
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
391
+ assert result.get("asyncTimeout") == 5000
392
+
260
393
 
261
394
  class TestClaudeAgentOptionsIntegration:
262
395
  """Test that callbacks work through ClaudeAgentOptions."""
@@ -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."""
@@ -106,7 +108,7 @@ class TestSubprocessCLITransport:
106
108
  options=ClaudeAgentOptions(
107
109
  allowed_tools=["Read", "Write"],
108
110
  disallowed_tools=["Bash"],
109
- model="claude-3-5-sonnet",
111
+ model="claude-sonnet-4-5",
110
112
  permission_mode="acceptEdits",
111
113
  max_turns=5,
112
114
  ),
@@ -119,7 +121,7 @@ class TestSubprocessCLITransport:
119
121
  assert "--disallowedTools" in cmd
120
122
  assert "Bash" in cmd
121
123
  assert "--model" in cmd
122
- assert "claude-3-5-sonnet" in cmd
124
+ assert "claude-sonnet-4-5" in cmd
123
125
  assert "--permission-mode" in cmd
124
126
  assert "acceptEdits" in cmd
125
127
  assert "--max-turns" in cmd
@@ -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."""
@@ -163,6 +171,16 @@ class TestSubprocessCLITransport:
163
171
 
164
172
  async def _test():
165
173
  with patch("anyio.open_process") as mock_exec:
174
+ # Mock version check process
175
+ mock_version_process = MagicMock()
176
+ mock_version_process.stdout = MagicMock()
177
+ mock_version_process.stdout.receive = AsyncMock(
178
+ return_value=b"2.0.0 (Claude Code)"
179
+ )
180
+ mock_version_process.terminate = MagicMock()
181
+ mock_version_process.wait = AsyncMock()
182
+
183
+ # Mock main process
166
184
  mock_process = MagicMock()
167
185
  mock_process.returncode = None
168
186
  mock_process.terminate = MagicMock()
@@ -175,7 +193,8 @@ class TestSubprocessCLITransport:
175
193
  mock_stdin.aclose = AsyncMock()
176
194
  mock_process.stdin = mock_stdin
177
195
 
178
- mock_exec.return_value = mock_process
196
+ # Return version process first, then main process
197
+ mock_exec.side_effect = [mock_version_process, mock_process]
179
198
 
180
199
  transport = SubprocessCLITransport(
181
200
  prompt="test",
@@ -311,28 +330,31 @@ class TestSubprocessCLITransport:
311
330
  from pathlib import Path
312
331
 
313
332
  # Test with string path
333
+ string_path = "/path/to/mcp-config.json"
314
334
  transport = SubprocessCLITransport(
315
335
  prompt="test",
316
- options=ClaudeAgentOptions(mcp_servers="/path/to/mcp-config.json"),
336
+ options=ClaudeAgentOptions(mcp_servers=string_path),
317
337
  cli_path="/usr/bin/claude",
318
338
  )
319
339
 
320
340
  cmd = transport._build_command()
321
341
  assert "--mcp-config" in cmd
322
342
  mcp_idx = cmd.index("--mcp-config")
323
- assert cmd[mcp_idx + 1] == "/path/to/mcp-config.json"
343
+ assert cmd[mcp_idx + 1] == string_path
324
344
 
325
345
  # Test with Path object
346
+ path_obj = Path("/path/to/mcp-config.json")
326
347
  transport = SubprocessCLITransport(
327
348
  prompt="test",
328
- options=ClaudeAgentOptions(mcp_servers=Path("/path/to/mcp-config.json")),
349
+ options=ClaudeAgentOptions(mcp_servers=path_obj),
329
350
  cli_path="/usr/bin/claude",
330
351
  )
331
352
 
332
353
  cmd = transport._build_command()
333
354
  assert "--mcp-config" in cmd
334
355
  mcp_idx = cmd.index("--mcp-config")
335
- 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)
336
358
 
337
359
  def test_build_command_with_mcp_servers_as_json_string(self):
338
360
  """Test building CLI command with mcp_servers as JSON string."""
@@ -363,13 +385,25 @@ class TestSubprocessCLITransport:
363
385
  with patch(
364
386
  "anyio.open_process", new_callable=AsyncMock
365
387
  ) as mock_open_process:
388
+ # Mock version check process
389
+ mock_version_process = MagicMock()
390
+ mock_version_process.stdout = MagicMock()
391
+ mock_version_process.stdout.receive = AsyncMock(
392
+ return_value=b"2.0.0 (Claude Code)"
393
+ )
394
+ mock_version_process.terminate = MagicMock()
395
+ mock_version_process.wait = AsyncMock()
396
+
397
+ # Mock main process
366
398
  mock_process = MagicMock()
367
399
  mock_process.stdout = MagicMock()
368
400
  mock_stdin = MagicMock()
369
401
  mock_stdin.aclose = AsyncMock() # Add async aclose method
370
402
  mock_process.stdin = mock_stdin
371
403
  mock_process.returncode = None
372
- mock_open_process.return_value = mock_process
404
+
405
+ # Return version process first, then main process
406
+ mock_open_process.side_effect = [mock_version_process, mock_process]
373
407
 
374
408
  transport = SubprocessCLITransport(
375
409
  prompt="test",
@@ -379,11 +413,13 @@ class TestSubprocessCLITransport:
379
413
 
380
414
  await transport.connect()
381
415
 
382
- # Verify open_process was called with correct env vars
383
- mock_open_process.assert_called_once()
384
- call_kwargs = mock_open_process.call_args.kwargs
385
- assert "env" in call_kwargs
386
- env_passed = call_kwargs["env"]
416
+ # Verify open_process was called twice (version check + main process)
417
+ assert mock_open_process.call_count == 2
418
+
419
+ # Check the second call (main process) for env vars
420
+ second_call_kwargs = mock_open_process.call_args_list[1].kwargs
421
+ assert "env" in second_call_kwargs
422
+ env_passed = second_call_kwargs["env"]
387
423
 
388
424
  # Check that custom env var was passed
389
425
  assert env_passed["MY_TEST_VAR"] == test_value
@@ -410,13 +446,25 @@ class TestSubprocessCLITransport:
410
446
  with patch(
411
447
  "anyio.open_process", new_callable=AsyncMock
412
448
  ) as mock_open_process:
449
+ # Mock version check process
450
+ mock_version_process = MagicMock()
451
+ mock_version_process.stdout = MagicMock()
452
+ mock_version_process.stdout.receive = AsyncMock(
453
+ return_value=b"2.0.0 (Claude Code)"
454
+ )
455
+ mock_version_process.terminate = MagicMock()
456
+ mock_version_process.wait = AsyncMock()
457
+
458
+ # Mock main process
413
459
  mock_process = MagicMock()
414
460
  mock_process.stdout = MagicMock()
415
461
  mock_stdin = MagicMock()
416
462
  mock_stdin.aclose = AsyncMock() # Add async aclose method
417
463
  mock_process.stdin = mock_stdin
418
464
  mock_process.returncode = None
419
- mock_open_process.return_value = mock_process
465
+
466
+ # Return version process first, then main process
467
+ mock_open_process.side_effect = [mock_version_process, mock_process]
420
468
 
421
469
  transport = SubprocessCLITransport(
422
470
  prompt="test",
@@ -426,11 +474,13 @@ class TestSubprocessCLITransport:
426
474
 
427
475
  await transport.connect()
428
476
 
429
- # Verify open_process was called with correct user
430
- mock_open_process.assert_called_once()
431
- call_kwargs = mock_open_process.call_args.kwargs
432
- assert "user" in call_kwargs
433
- user_passed = call_kwargs["user"]
477
+ # Verify open_process was called twice (version check + main process)
478
+ assert mock_open_process.call_count == 2
479
+
480
+ # Check the second call (main process) for user
481
+ second_call_kwargs = mock_open_process.call_args_list[1].kwargs
482
+ assert "user" in second_call_kwargs
483
+ user_passed = second_call_kwargs["user"]
434
484
 
435
485
  # Check that user was passed
436
486
  assert user_passed == "claude"
@@ -145,7 +145,7 @@ class TestOptions:
145
145
  def test_claude_code_options_with_model_specification(self):
146
146
  """Test Options with model specification."""
147
147
  options = ClaudeAgentOptions(
148
- model="claude-3-5-sonnet-20241022", permission_prompt_tool_name="CustomTool"
148
+ model="claude-sonnet-4-5", permission_prompt_tool_name="CustomTool"
149
149
  )
150
- assert options.model == "claude-3-5-sonnet-20241022"
150
+ assert options.model == "claude-sonnet-4-5"
151
151
  assert options.permission_prompt_tool_name == "CustomTool"