claude-agent-sdk 0.0.23__tar.gz → 0.1.1__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 (32) hide show
  1. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/PKG-INFO +13 -4
  2. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/README.md +12 -3
  3. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/pyproject.toml +1 -1
  4. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_internal/query.py +10 -5
  5. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +56 -14
  6. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_version.py +1 -1
  7. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/client.py +18 -8
  8. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/types.py +37 -0
  9. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_integration.py +1 -1
  10. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_subprocess_buffering.py +30 -2
  11. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_tool_callbacks.py +4 -4
  12. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_transport.py +55 -16
  13. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_types.py +2 -2
  14. claude_agent_sdk-0.0.23/tests/mock_transport.py +0 -177
  15. claude_agent_sdk-0.0.23/tests/test_hooks.py +0 -148
  16. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/.gitignore +0 -0
  17. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/LICENSE +0 -0
  18. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/__init__.py +0 -0
  19. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_errors.py +0 -0
  20. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_internal/__init__.py +0 -0
  21. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_internal/client.py +0 -0
  22. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_internal/message_parser.py +0 -0
  23. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/_internal/transport/__init__.py +0 -0
  24. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/py.typed +0 -0
  25. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/src/claude_agent_sdk/query.py +0 -0
  26. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/conftest.py +0 -0
  27. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_changelog.py +0 -0
  28. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_client.py +0 -0
  29. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_errors.py +0 -0
  30. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_message_parser.py +0 -0
  31. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_sdk_mcp_integration.py +0 -0
  32. {claude_agent_sdk-0.0.23 → claude_agent_sdk-0.1.1}/tests/test_streaming_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-agent-sdk
3
- Version: 0.0.23
3
+ Version: 0.1.1
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.0.23"
7
+ version = "0.1.1"
8
8
  description = "Python SDK for Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -213,13 +213,18 @@ class Query:
213
213
 
214
214
  # Convert PermissionResult to expected dict format
215
215
  if isinstance(response, PermissionResultAllow):
216
- response_data = {"allow": True}
216
+ response_data = {"behavior": "allow"}
217
217
  if response.updated_input is not None:
218
- response_data["input"] = response.updated_input
219
- # TODO: Handle updatedPermissions when control protocol supports it
218
+ response_data["updatedInput"] = response.updated_input
219
+ if response.updated_permissions is not None:
220
+ response_data["updatedPermissions"] = [
221
+ permission.to_dict()
222
+ for permission in response.updated_permissions
223
+ ]
220
224
  elif isinstance(response, PermissionResultDeny):
221
- response_data = {"allow": False, "reason": response.message}
222
- # TODO: Handle interrupt flag when control protocol supports it
225
+ response_data = {"behavior": "deny", "message": response.message}
226
+ if response.interrupt:
227
+ response_data["interrupt"] = response.interrupt
223
228
  else:
224
229
  raise TypeError(
225
230
  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
@@ -24,7 +26,8 @@ from . import Transport
24
26
 
25
27
  logger = logging.getLogger(__name__)
26
28
 
27
- _MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
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):
@@ -48,6 +51,11 @@ class SubprocessCLITransport(Transport):
48
51
  self._stderr_task_group: anyio.abc.TaskGroup | None = None
49
52
  self._ready = False
50
53
  self._exit_error: Exception | None = None # Track process exit errors
54
+ self._max_buffer_size = (
55
+ options.max_buffer_size
56
+ if options.max_buffer_size is not None
57
+ else _DEFAULT_MAX_BUFFER_SIZE
58
+ )
51
59
 
52
60
  def _find_cli(self) -> str:
53
61
  """Find Claude Code CLI binary."""
@@ -66,15 +74,6 @@ class SubprocessCLITransport(Transport):
66
74
  if path.exists() and path.is_file():
67
75
  return str(path)
68
76
 
69
- node_installed = shutil.which("node") is not None
70
-
71
- if not node_installed:
72
- error_msg = "Claude Code requires Node.js, which is not installed.\n\n"
73
- error_msg += "Install Node.js from: https://nodejs.org/\n"
74
- error_msg += "\nAfter installing Node.js, install Claude Code:\n"
75
- error_msg += " npm install -g @anthropic-ai/claude-code"
76
- raise CLINotFoundError(error_msg)
77
-
78
77
  raise CLINotFoundError(
79
78
  "Claude Code not found. Install with:\n"
80
79
  " npm install -g @anthropic-ai/claude-code\n"
@@ -206,6 +205,8 @@ class SubprocessCLITransport(Transport):
206
205
  if self._process:
207
206
  return
208
207
 
208
+ await self._check_claude_version()
209
+
209
210
  cmd = self._build_command()
210
211
  try:
211
212
  # Merge environment variables: system -> user -> SDK required
@@ -411,12 +412,13 @@ class SubprocessCLITransport(Transport):
411
412
  # Keep accumulating partial JSON until we can parse it
412
413
  json_buffer += json_line
413
414
 
414
- if len(json_buffer) > _MAX_BUFFER_SIZE:
415
+ if len(json_buffer) > self._max_buffer_size:
416
+ buffer_length = len(json_buffer)
415
417
  json_buffer = ""
416
418
  raise SDKJSONDecodeError(
417
- f"JSON message exceeded maximum buffer size of {_MAX_BUFFER_SIZE} bytes",
419
+ f"JSON message exceeded maximum buffer size of {self._max_buffer_size} bytes",
418
420
  ValueError(
419
- f"Buffer size {len(json_buffer)} exceeds limit {_MAX_BUFFER_SIZE}"
421
+ f"Buffer size {buffer_length} exceeds limit {self._max_buffer_size}"
420
422
  ),
421
423
  )
422
424
 
@@ -427,7 +429,7 @@ class SubprocessCLITransport(Transport):
427
429
  except json.JSONDecodeError:
428
430
  # We are speculatively decoding the buffer until we get
429
431
  # a full JSON object. If there is an actual issue, we
430
- # raise an error after _MAX_BUFFER_SIZE.
432
+ # raise an error after exceeding the configured limit.
431
433
  continue
432
434
 
433
435
  except anyio.ClosedResourceError:
@@ -451,6 +453,46 @@ class SubprocessCLITransport(Transport):
451
453
  )
452
454
  raise self._exit_error
453
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
+
454
496
  def is_ready(self) -> bool:
455
497
  """Check if transport is ready for communication."""
456
498
  return self._ready
@@ -1,3 +1,3 @@
1
1
  """Version information for claude-agent-sdk."""
2
2
 
3
- __version__ = "0.0.23"
3
+ __version__ = "0.1.1"
@@ -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
@@ -320,6 +356,7 @@ class ClaudeAgentOptions:
320
356
  extra_args: dict[str, str | None] = field(
321
357
  default_factory=dict
322
358
  ) # Pass arbitrary CLI flags
359
+ max_buffer_size: int | None = None # Max bytes when buffering CLI stdout
323
360
  debug_stderr: Any = (
324
361
  sys.stderr
325
362
  ) # Deprecated: File-like object for debug output. Use stderr callback instead.
@@ -161,7 +161,7 @@ class TestIntegration:
161
161
  async for _ in query(prompt="test"):
162
162
  pass
163
163
 
164
- assert "Claude Code requires Node.js" in str(exc_info.value)
164
+ assert "Claude Code not found" in str(exc_info.value)
165
165
 
166
166
  anyio.run(_test)
167
167
 
@@ -10,7 +10,7 @@ import pytest
10
10
 
11
11
  from claude_agent_sdk._errors import CLIJSONDecodeError
12
12
  from claude_agent_sdk._internal.transport.subprocess_cli import (
13
- _MAX_BUFFER_SIZE,
13
+ _DEFAULT_MAX_BUFFER_SIZE,
14
14
  SubprocessCLITransport,
15
15
  )
16
16
  from claude_agent_sdk.types import ClaudeAgentOptions
@@ -237,7 +237,7 @@ class TestSubprocessBuffering:
237
237
  """Test that exceeding buffer size raises an appropriate error."""
238
238
 
239
239
  async def _test() -> None:
240
- huge_incomplete = '{"data": "' + "x" * (_MAX_BUFFER_SIZE + 1000)
240
+ huge_incomplete = '{"data": "' + "x" * (_DEFAULT_MAX_BUFFER_SIZE + 1000)
241
241
 
242
242
  transport = SubprocessCLITransport(
243
243
  prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude"
@@ -260,6 +260,34 @@ class TestSubprocessBuffering:
260
260
 
261
261
  anyio.run(_test)
262
262
 
263
+ def test_buffer_size_option(self) -> None:
264
+ """Test that the configurable buffer size option is respected."""
265
+
266
+ async def _test() -> None:
267
+ custom_limit = 512
268
+ huge_incomplete = '{"data": "' + "x" * (custom_limit + 10)
269
+
270
+ transport = SubprocessCLITransport(
271
+ prompt="test",
272
+ options=ClaudeAgentOptions(max_buffer_size=custom_limit),
273
+ cli_path="/usr/bin/claude",
274
+ )
275
+
276
+ mock_process = MagicMock()
277
+ mock_process.returncode = None
278
+ mock_process.wait = AsyncMock(return_value=None)
279
+ transport._process = mock_process
280
+ transport._stdout_stream = MockTextReceiveStream([huge_incomplete])
281
+ transport._stderr_stream = MockTextReceiveStream([])
282
+
283
+ with pytest.raises(CLIJSONDecodeError) as exc_info:
284
+ async for _ in transport.read_messages():
285
+ pass
286
+
287
+ assert f"maximum buffer size of {custom_limit} bytes" in str(exc_info.value)
288
+
289
+ anyio.run(_test)
290
+
263
291
  def test_mixed_complete_and_split_json(self) -> None:
264
292
  """Test handling a mix of complete and split JSON messages."""
265
293
 
@@ -90,7 +90,7 @@ class TestToolPermissionCallbacks:
90
90
  # Check response was sent
91
91
  assert len(transport.written_messages) == 1
92
92
  response = transport.written_messages[0]
93
- assert '"allow": true' in response
93
+ assert '"behavior": "allow"' in response
94
94
 
95
95
  @pytest.mark.asyncio
96
96
  async def test_permission_callback_deny(self):
@@ -125,8 +125,8 @@ class TestToolPermissionCallbacks:
125
125
  # Check response
126
126
  assert len(transport.written_messages) == 1
127
127
  response = transport.written_messages[0]
128
- assert '"allow": false' in response
129
- assert '"reason": "Security policy violation"' in response
128
+ assert '"behavior": "deny"' in response
129
+ assert '"message": "Security policy violation"' in response
130
130
 
131
131
  @pytest.mark.asyncio
132
132
  async def test_permission_callback_input_modification(self):
@@ -164,7 +164,7 @@ class TestToolPermissionCallbacks:
164
164
  # Check response includes modified input
165
165
  assert len(transport.written_messages) == 1
166
166
  response = transport.written_messages[0]
167
- assert '"allow": true' in response
167
+ assert '"behavior": "allow"' in response
168
168
  assert '"safe_mode": true' in response
169
169
 
170
170
  @pytest.mark.asyncio
@@ -25,7 +25,7 @@ class TestSubprocessCLITransport:
25
25
  ):
26
26
  SubprocessCLITransport(prompt="test", options=ClaudeAgentOptions())
27
27
 
28
- assert "Claude Code requires Node.js" in str(exc_info.value)
28
+ assert "Claude Code not found" in str(exc_info.value)
29
29
 
30
30
  def test_build_command_basic(self):
31
31
  """Test building basic CLI command."""
@@ -106,7 +106,7 @@ class TestSubprocessCLITransport:
106
106
  options=ClaudeAgentOptions(
107
107
  allowed_tools=["Read", "Write"],
108
108
  disallowed_tools=["Bash"],
109
- model="claude-3-5-sonnet",
109
+ model="claude-sonnet-4-5",
110
110
  permission_mode="acceptEdits",
111
111
  max_turns=5,
112
112
  ),
@@ -119,7 +119,7 @@ class TestSubprocessCLITransport:
119
119
  assert "--disallowedTools" in cmd
120
120
  assert "Bash" in cmd
121
121
  assert "--model" in cmd
122
- assert "claude-3-5-sonnet" in cmd
122
+ assert "claude-sonnet-4-5" in cmd
123
123
  assert "--permission-mode" in cmd
124
124
  assert "acceptEdits" in cmd
125
125
  assert "--max-turns" in cmd
@@ -163,6 +163,16 @@ class TestSubprocessCLITransport:
163
163
 
164
164
  async def _test():
165
165
  with patch("anyio.open_process") as mock_exec:
166
+ # Mock version check process
167
+ mock_version_process = MagicMock()
168
+ mock_version_process.stdout = MagicMock()
169
+ mock_version_process.stdout.receive = AsyncMock(
170
+ return_value=b"2.0.0 (Claude Code)"
171
+ )
172
+ mock_version_process.terminate = MagicMock()
173
+ mock_version_process.wait = AsyncMock()
174
+
175
+ # Mock main process
166
176
  mock_process = MagicMock()
167
177
  mock_process.returncode = None
168
178
  mock_process.terminate = MagicMock()
@@ -175,7 +185,8 @@ class TestSubprocessCLITransport:
175
185
  mock_stdin.aclose = AsyncMock()
176
186
  mock_process.stdin = mock_stdin
177
187
 
178
- mock_exec.return_value = mock_process
188
+ # Return version process first, then main process
189
+ mock_exec.side_effect = [mock_version_process, mock_process]
179
190
 
180
191
  transport = SubprocessCLITransport(
181
192
  prompt="test",
@@ -363,13 +374,25 @@ class TestSubprocessCLITransport:
363
374
  with patch(
364
375
  "anyio.open_process", new_callable=AsyncMock
365
376
  ) as mock_open_process:
377
+ # Mock version check process
378
+ mock_version_process = MagicMock()
379
+ mock_version_process.stdout = MagicMock()
380
+ mock_version_process.stdout.receive = AsyncMock(
381
+ return_value=b"2.0.0 (Claude Code)"
382
+ )
383
+ mock_version_process.terminate = MagicMock()
384
+ mock_version_process.wait = AsyncMock()
385
+
386
+ # Mock main process
366
387
  mock_process = MagicMock()
367
388
  mock_process.stdout = MagicMock()
368
389
  mock_stdin = MagicMock()
369
390
  mock_stdin.aclose = AsyncMock() # Add async aclose method
370
391
  mock_process.stdin = mock_stdin
371
392
  mock_process.returncode = None
372
- mock_open_process.return_value = mock_process
393
+
394
+ # Return version process first, then main process
395
+ mock_open_process.side_effect = [mock_version_process, mock_process]
373
396
 
374
397
  transport = SubprocessCLITransport(
375
398
  prompt="test",
@@ -379,11 +402,13 @@ class TestSubprocessCLITransport:
379
402
 
380
403
  await transport.connect()
381
404
 
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"]
405
+ # Verify open_process was called twice (version check + main process)
406
+ assert mock_open_process.call_count == 2
407
+
408
+ # Check the second call (main process) for env vars
409
+ second_call_kwargs = mock_open_process.call_args_list[1].kwargs
410
+ assert "env" in second_call_kwargs
411
+ env_passed = second_call_kwargs["env"]
387
412
 
388
413
  # Check that custom env var was passed
389
414
  assert env_passed["MY_TEST_VAR"] == test_value
@@ -410,13 +435,25 @@ class TestSubprocessCLITransport:
410
435
  with patch(
411
436
  "anyio.open_process", new_callable=AsyncMock
412
437
  ) as mock_open_process:
438
+ # Mock version check process
439
+ mock_version_process = MagicMock()
440
+ mock_version_process.stdout = MagicMock()
441
+ mock_version_process.stdout.receive = AsyncMock(
442
+ return_value=b"2.0.0 (Claude Code)"
443
+ )
444
+ mock_version_process.terminate = MagicMock()
445
+ mock_version_process.wait = AsyncMock()
446
+
447
+ # Mock main process
413
448
  mock_process = MagicMock()
414
449
  mock_process.stdout = MagicMock()
415
450
  mock_stdin = MagicMock()
416
451
  mock_stdin.aclose = AsyncMock() # Add async aclose method
417
452
  mock_process.stdin = mock_stdin
418
453
  mock_process.returncode = None
419
- mock_open_process.return_value = mock_process
454
+
455
+ # Return version process first, then main process
456
+ mock_open_process.side_effect = [mock_version_process, mock_process]
420
457
 
421
458
  transport = SubprocessCLITransport(
422
459
  prompt="test",
@@ -426,11 +463,13 @@ class TestSubprocessCLITransport:
426
463
 
427
464
  await transport.connect()
428
465
 
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"]
466
+ # Verify open_process was called twice (version check + main process)
467
+ assert mock_open_process.call_count == 2
468
+
469
+ # Check the second call (main process) for user
470
+ second_call_kwargs = mock_open_process.call_args_list[1].kwargs
471
+ assert "user" in second_call_kwargs
472
+ user_passed = second_call_kwargs["user"]
434
473
 
435
474
  # Check that user was passed
436
475
  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"
@@ -1,177 +0,0 @@
1
- """Mock transport implementation for testing."""
2
-
3
- import json
4
- from collections.abc import AsyncIterator
5
- from typing import Any
6
-
7
- import anyio
8
-
9
- from claude_agent_sdk._internal.transport import Transport
10
-
11
-
12
- class MockTransport(Transport):
13
- """Mock transport for testing Query and Client behavior.
14
-
15
- This transport allows tests to:
16
- - Capture all messages written by the SDK
17
- - Simulate responses from the CLI
18
- - Control message flow timing
19
- """
20
-
21
- def __init__(self):
22
- """Initialize mock transport."""
23
- self.written_messages: list[dict[str, Any]] = []
24
- self.messages_to_read: list[dict[str, Any]] = []
25
- self._ready = False
26
- self._read_index = 0
27
- self._write_delay = 0.0 # Optional delay for write operations
28
- self._read_delay = 0.0 # Optional delay between messages
29
-
30
- async def connect(self) -> None:
31
- """Mark transport as ready."""
32
- self._ready = True
33
-
34
- async def write(self, data: str) -> None:
35
- """Capture written messages.
36
-
37
- Args:
38
- data: JSON string with newline
39
- """
40
- if self._write_delay > 0:
41
- await anyio.sleep(self._write_delay)
42
-
43
- # Parse and store the message
44
- message = json.loads(data.strip())
45
- self.written_messages.append(message)
46
-
47
- async def read_messages(self) -> AsyncIterator[dict[str, Any]]:
48
- """Yield messages from the configured list.
49
-
50
- Yields:
51
- Messages added via add_message_to_read()
52
- """
53
- # Auto-respond to initialize requests
54
- responded_to_init = False
55
-
56
- while self._ready:
57
- # Check for initialize requests we need to respond to
58
- if not responded_to_init:
59
- for msg in self.written_messages:
60
- if (
61
- msg.get("type") == "control_request"
62
- and msg.get("request", {}).get("subtype") == "initialize"
63
- ):
64
- # Auto-send initialize response
65
- responded_to_init = True
66
- yield {
67
- "type": "control_response",
68
- "response": {
69
- "subtype": "success",
70
- "request_id": msg.get("request_id"),
71
- "response": {
72
- "commands": [],
73
- "output_style": "default",
74
- "hooks": [], # Will be populated by Query.initialize
75
- },
76
- },
77
- }
78
- break
79
-
80
- # Yield any manually added messages
81
- if self._read_index < len(self.messages_to_read):
82
- if self._read_delay > 0:
83
- await anyio.sleep(self._read_delay)
84
-
85
- message = self.messages_to_read[self._read_index]
86
- self._read_index += 1
87
- yield message
88
- else:
89
- # Small delay to avoid busy loop
90
- await anyio.sleep(0.01)
91
-
92
- async def close(self) -> None:
93
- """Mark transport as not ready."""
94
- self._ready = False
95
-
96
- def is_ready(self) -> bool:
97
- """Check if transport is ready.
98
-
99
- Returns:
100
- True if connect() was called and close() wasn't
101
- """
102
- return self._ready
103
-
104
- async def end_input(self) -> None:
105
- """No-op for mock transport."""
106
- pass
107
-
108
- # Helper methods for testing
109
-
110
- def add_message_to_read(self, message: dict[str, Any]) -> None:
111
- """Add a message that will be returned by read_messages().
112
-
113
- Args:
114
- message: Message dict to be yielded by read_messages()
115
- """
116
- self.messages_to_read.append(message)
117
-
118
- def add_control_request(self, subtype: str, request_id: str, **kwargs: Any) -> None:
119
- """Helper to add a control request message.
120
-
121
- Args:
122
- subtype: Control request subtype (e.g., "initialize", "hook_callback")
123
- request_id: Request ID
124
- **kwargs: Additional request fields
125
- """
126
- self.add_message_to_read(
127
- {
128
- "type": "control_request",
129
- "request_id": request_id,
130
- "request": {"subtype": subtype, **kwargs},
131
- }
132
- )
133
-
134
- def get_written_messages(self, msg_type: str | None = None) -> list[dict[str, Any]]:
135
- """Get written messages, optionally filtered by type.
136
-
137
- Args:
138
- msg_type: Optional message type to filter by
139
-
140
- Returns:
141
- List of written messages
142
- """
143
- if msg_type is None:
144
- return self.written_messages
145
- return [msg for msg in self.written_messages if msg.get("type") == msg_type]
146
-
147
- def get_control_responses(self) -> list[dict[str, Any]]:
148
- """Get all control response messages that were written.
149
-
150
- Returns:
151
- List of control response messages
152
- """
153
- return self.get_written_messages("control_response")
154
-
155
- def get_last_response(self) -> dict[str, Any] | None:
156
- """Get the last written message.
157
-
158
- Returns:
159
- Last written message or None if no messages
160
- """
161
- return self.written_messages[-1] if self.written_messages else None
162
-
163
- def clear(self) -> None:
164
- """Clear all messages and reset state."""
165
- self.written_messages.clear()
166
- self.messages_to_read.clear()
167
- self._read_index = 0
168
-
169
- def set_delays(self, write_delay: float = 0.0, read_delay: float = 0.0) -> None:
170
- """Set artificial delays for testing timing issues.
171
-
172
- Args:
173
- write_delay: Delay in seconds for write operations
174
- read_delay: Delay in seconds between read messages
175
- """
176
- self._write_delay = write_delay
177
- self._read_delay = read_delay
@@ -1,148 +0,0 @@
1
- """Tests for the hooks decorator API."""
2
-
3
- import anyio
4
-
5
- from claude_agent_sdk.hooks import (
6
- PreToolUseHookResponse,
7
- clear_registry,
8
- get_registry,
9
- post_tool_use,
10
- pre_tool_use,
11
- )
12
- from claude_agent_sdk.hooks.executor import execute_hook
13
-
14
-
15
- class TestHooksAPI:
16
- """Test the decorator-based hooks API."""
17
-
18
- def setup_method(self):
19
- """Clear registry before each test."""
20
- clear_registry()
21
-
22
- def test_decorator_registers_and_executes(self):
23
- """Test that decorators register hooks and they execute properly."""
24
-
25
- async def _test():
26
- executed = []
27
-
28
- # Test pre_tool_use decorator
29
- @pre_tool_use(matcher="Bash", timeout=5)
30
- def check_dangerous_commands(tool_name, tool_input):
31
- executed.append(("pre", tool_name, tool_input.to_dict()))
32
- if "rm -rf /" in tool_input.get("command", ""):
33
- return PreToolUseHookResponse(
34
- permission_decision="deny",
35
- permission_decision_reason="Dangerous command blocked",
36
- )
37
- return None # Allow
38
-
39
- # Verify registration
40
- registry = get_registry()
41
- hooks = registry.get_hooks("PreToolUse")
42
- assert len(hooks) == 1
43
- assert hooks[0].matcher == "Bash"
44
- assert hooks[0].timeout == 5
45
-
46
- # Test execution with dangerous command
47
- result = await execute_hook(
48
- check_dangerous_commands,
49
- {"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}},
50
- )
51
- assert isinstance(result, PreToolUseHookResponse)
52
- assert result.permission_decision == "deny"
53
- assert "Dangerous command blocked" in result.permission_decision_reason
54
-
55
- # Test execution with safe command
56
- result = await execute_hook(
57
- check_dangerous_commands,
58
- {"tool_name": "Bash", "tool_input": {"command": "ls -la"}},
59
- )
60
- assert result is None # Allowed
61
-
62
- # Verify hook was called
63
- assert len(executed) == 2
64
- assert executed[0][0] == "pre"
65
- assert executed[0][1] == "Bash"
66
-
67
- anyio.run(_test)
68
-
69
- def test_registry_lifecycle(self):
70
- """Test registry clear and isolation between tests."""
71
-
72
- # Register some hooks
73
- @pre_tool_use()
74
- def hook1():
75
- pass
76
-
77
- @post_tool_use(matcher="Write")
78
- def hook2():
79
- pass
80
-
81
- registry = get_registry()
82
- assert len(registry.get_hooks("PreToolUse")) == 1
83
- assert len(registry.get_hooks("PostToolUse")) == 1
84
-
85
- # Clear registry
86
- clear_registry()
87
- assert len(registry.get_hooks("PreToolUse")) == 0
88
- assert len(registry.get_hooks("PostToolUse")) == 0
89
-
90
- # Register new hooks - verify no interference
91
- @pre_tool_use(matcher="Read")
92
- def hook3():
93
- pass
94
-
95
- assert len(registry.get_hooks("PreToolUse")) == 1
96
- assert registry.get_hooks("PreToolUse")[0].matcher == "Read"
97
-
98
- def test_parameter_introspection(self):
99
- """Test that hooks only receive the parameters they request."""
100
-
101
- async def _test():
102
- received_params = {}
103
-
104
- # Hook that only wants tool_name
105
- def minimal_hook(tool_name):
106
- received_params["minimal"] = {"tool_name": tool_name}
107
- return None
108
-
109
- # Hook that wants multiple params
110
- def full_hook(tool_name, tool_input, tool_use_id):
111
- received_params["full"] = {
112
- "tool_name": tool_name,
113
- "tool_input": tool_input.to_dict(),
114
- "tool_use_id": tool_use_id,
115
- }
116
- return None
117
-
118
- # Test minimal hook - should only get tool_name
119
- await execute_hook(
120
- minimal_hook,
121
- {
122
- "tool_name": "TestTool",
123
- "tool_input": {"data": "test"},
124
- "extra": "ignored",
125
- },
126
- tool_use_id="123",
127
- )
128
-
129
- assert "minimal" in received_params
130
- assert received_params["minimal"] == {"tool_name": "TestTool"}
131
-
132
- # Test full hook - should get all requested params
133
- await execute_hook(
134
- full_hook,
135
- {
136
- "tool_name": "TestTool",
137
- "tool_input": {"data": "test"},
138
- "extra": "also_ignored",
139
- },
140
- tool_use_id="456",
141
- )
142
-
143
- assert "full" in received_params
144
- assert received_params["full"]["tool_name"] == "TestTool"
145
- assert received_params["full"]["tool_input"] == {"data": "test"}
146
- assert received_params["full"]["tool_use_id"] == "456"
147
-
148
- anyio.run(_test)