From 243703531b68ca7ffd9439df1bb2a14304adecd4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 3 Dec 2025 20:09:56 +0000 Subject: [PATCH 01/32] chore: bump bundled CLI version to 2.0.58 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 0335a9f..b45467a 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.57" +__cli_version__ = "2.0.58" From 4e56cb12a9832eb21b3ce08282b3c68810100c30 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Thu, 4 Dec 2025 09:26:01 -0800 Subject: [PATCH 02/32] feat: add SDK beta support with SdkBeta type and betas option (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the SdkBeta type and betas option from the TypeScript SDK to enable SDK users to pass beta feature flags (e.g., 1M context window) to the CLI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- src/claude_agent_sdk/__init__.py | 3 +++ src/claude_agent_sdk/_internal/transport/subprocess_cli.py | 3 +++ src/claude_agent_sdk/types.py | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 407bc9a..4898bc0 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -42,6 +42,7 @@ from .types import ( SandboxIgnoreViolations, SandboxNetworkConfig, SandboxSettings, + SdkBeta, SdkPluginConfig, SettingSource, StopHookInput, @@ -345,6 +346,8 @@ __all__ = [ "SettingSource", # Plugin support "SdkPluginConfig", + # Beta support + "SdkBeta", # Sandbox support "SandboxSettings", "SandboxNetworkConfig", diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 73c1b29..6542cde 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -203,6 +203,9 @@ class SubprocessCLITransport(Transport): if self._options.fallback_model: cmd.extend(["--fallback-model", self._options.fallback_model]) + if self._options.betas: + cmd.extend(["--betas", ",".join(self._options.betas)]) + if self._options.permission_prompt_tool_name: cmd.extend( ["--permission-prompt-tool", self._options.permission_prompt_tool_name] diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index f37fd3c..9a9800e 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -14,6 +14,9 @@ if TYPE_CHECKING: # Permission modes PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] +# SDK Beta features - see https://docs.anthropic.com/en/api/beta-headers +SdkBeta = Literal["context-1m-2025-08-07"] + # Agent definitions SettingSource = Literal["user", "project", "local"] @@ -614,6 +617,8 @@ class ClaudeAgentOptions: disallowed_tools: list[str] = field(default_factory=list) model: str | None = None fallback_model: str | None = None + # Beta features - see https://docs.anthropic.com/en/api/beta-headers + betas: list[SdkBeta] = field(default_factory=list) permission_prompt_tool_name: str | None = None cwd: str | Path | None = None cli_path: str | Path | None = None From ea0ef25e71d347a3a71b80357f34d34eb2875d1a Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Thu, 4 Dec 2025 09:27:02 -0800 Subject: [PATCH 03/32] feat: add tools option to ClaudeAgentOptions (#389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for the `tools` option matching the TypeScript SDK, which controls the base set of available tools separately from allowed/disallowed tool filtering. Supports three modes: - Array of tool names: `["Read", "Edit", "Bash"]` - Empty array: `[]` (disables all built-in tools) - Preset object: `{"type": "preset", "preset": "claude_code"}` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude --- examples/tools_option.py | 111 ++++++++++++++++++ .../_internal/transport/subprocess_cli.py | 12 ++ src/claude_agent_sdk/types.py | 8 ++ tests/test_transport.py | 46 ++++++++ 4 files changed, 177 insertions(+) create mode 100644 examples/tools_option.py diff --git a/examples/tools_option.py b/examples/tools_option.py new file mode 100644 index 0000000..204676f --- /dev/null +++ b/examples/tools_option.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Example demonstrating the tools option and verifying tools in system message.""" + +import anyio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + SystemMessage, + TextBlock, + query, +) + + +async def tools_array_example(): + """Example with tools as array of specific tool names.""" + print("=== Tools Array Example ===") + print("Setting tools=['Read', 'Glob', 'Grep']") + print() + + options = ClaudeAgentOptions( + tools=["Read", "Glob", "Grep"], + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message: {tools}") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def tools_empty_array_example(): + """Example with tools as empty array (disables all built-in tools).""" + print("=== Tools Empty Array Example ===") + print("Setting tools=[] (disables all built-in tools)") + print() + + options = ClaudeAgentOptions( + tools=[], + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message: {tools}") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def tools_preset_example(): + """Example with tools preset (all default Claude Code tools).""" + print("=== Tools Preset Example ===") + print("Setting tools={'type': 'preset', 'preset': 'claude_code'}") + print() + + options = ClaudeAgentOptions( + tools={"type": "preset", "preset": "claude_code"}, + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message ({len(tools)} tools): {tools[:5]}...") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def main(): + """Run all examples.""" + await tools_array_example() + await tools_empty_array_example() + await tools_preset_example() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 6542cde..26bd2ec 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -185,6 +185,18 @@ class SubprocessCLITransport(Transport): ["--append-system-prompt", self._options.system_prompt["append"]] ) + # Handle tools option (base set of tools) + if self._options.tools is not None: + tools = self._options.tools + if isinstance(tools, list): + if len(tools) == 0: + cmd.extend(["--tools", ""]) + else: + cmd.extend(["--tools", ",".join(tools)]) + else: + # Preset object - 'claude_code' preset maps to 'default' + cmd.extend(["--tools", "default"]) + if self._options.allowed_tools: cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)]) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 9a9800e..391ff95 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -29,6 +29,13 @@ class SystemPromptPreset(TypedDict): append: NotRequired[str] +class ToolsPreset(TypedDict): + """Tools preset configuration.""" + + type: Literal["preset"] + preset: Literal["claude_code"] + + @dataclass class AgentDefinition: """Agent definition configuration.""" @@ -606,6 +613,7 @@ Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | Strea class ClaudeAgentOptions: """Query options for Claude SDK.""" + tools: list[str] | ToolsPreset | None = None allowed_tools: list[str] = field(default_factory=list) system_prompt: str | SystemPromptPreset | None = None mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict) diff --git a/tests/test_transport.py b/tests/test_transport.py index b834671..c634fc2 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -647,3 +647,49 @@ class TestSubprocessCLITransport: assert network["allowLocalBinding"] is True assert network["httpProxyPort"] == 8080 assert network["socksProxyPort"] == 8081 + + def test_build_command_with_tools_array(self): + """Test building CLI command with tools as array of tool names.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools=["Read", "Edit", "Bash"]), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "Read,Edit,Bash" + + def test_build_command_with_tools_empty_array(self): + """Test building CLI command with tools as empty array (disables all tools).""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools=[]), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "" + + def test_build_command_with_tools_preset(self): + """Test building CLI command with tools preset.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools={"type": "preset", "preset": "claude_code"}), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "default" + + def test_build_command_without_tools(self): + """Test building CLI command without tools option (default None).""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(), + ) + + cmd = transport._build_command() + assert "--tools" not in cmd From 00332f32dcb5121d063729293263a09f92240f82 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:30:12 -0800 Subject: [PATCH 04/32] chore: release v0.1.12 (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.1.12 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` to 0.1.12 - Updated version in `src/claude_agent_sdk/_version.py` to 0.1.12 - Updated `CHANGELOG.md` with release notes ## Release Information - Published to PyPI: https://pypi.org/project/claude-agent-sdk/0.1.12/ - Bundled CLI version: 2.0.58 - Install with: `pip install claude-agent-sdk==0.1.12` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Co-authored-by: Ashwin Bhat --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/claude_agent_sdk/_version.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a71177..8250a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.12 + +### New Features + +- **Tools option**: Added `tools` option to `ClaudeAgentOptions` for controlling the base set of available tools, matching the TypeScript SDK functionality. Supports three modes: + - Array of tool names to specify which tools should be available (e.g., `["Read", "Edit", "Bash"]`) + - Empty array `[]` to disable all built-in tools + - Preset object `{"type": "preset", "preset": "claude_code"}` to use the default Claude Code toolset +- **SDK beta support**: Added `betas` option to `ClaudeAgentOptions` for enabling Anthropic API beta features. Currently supports `"context-1m-2025-08-07"` for extended context window + ## 0.1.11 ### Internal/Other Changes diff --git a/pyproject.toml b/pyproject.toml index 2850005..f231338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.11" +version = "0.1.12" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index c8c57ba..4acf622 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.11" +__version__ = "0.1.12" From 2d67166cae749d2d416dd110ab440c07f114b02e Mon Sep 17 00:00:00 2001 From: Carlos Cuevas <6290853+CarlosCuevas@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:27:01 -0500 Subject: [PATCH 05/32] fix: add write lock to prevent concurrent transport writes (#391) ## TL;DR Adds a write lock to `SubprocessCLITransport` to prevent concurrent writes from parallel subagents. --- ## Overview When multiple subagents run in parallel and invoke MCP tools, the CLI sends concurrent `control_request` messages. Each handler tries to write a response back to the subprocess stdin at the same time. Trio's `TextSendStream` isn't thread-safe for concurrent access, so this causes `BusyResourceError`. This PR adds an `anyio.Lock` around all write operations (`write()`, `end_input()`, and the stdin-closing part of `close()`). The lock serializes concurrent writes so they happen one at a time. The `_ready` flag is now set inside the lock during `close()` to prevent a TOCTOU race where `write()` checks `_ready`, then `close()` sets it and closes the stream before `write()` actually sends data. --- ## Call Flow ```mermaid flowchart TD A["write()
subprocess_cli.py:505"] --> B["acquire _write_lock
subprocess_cli.py:507"] B --> C["check _ready & stream
subprocess_cli.py:509"] C --> D["_stdin_stream.send()
subprocess_cli.py:523"] E["close()
subprocess_cli.py:458"] --> F["acquire _write_lock
subprocess_cli.py:478"] F --> G["set _ready = False
subprocess_cli.py:479"] G --> H["close _stdin_stream
subprocess_cli.py:481"] I["end_input()
subprocess_cli.py:531"] --> J["acquire _write_lock
subprocess_cli.py:533"] J --> K["close _stdin_stream
subprocess_cli.py:535"] ``` --- .../_internal/transport/subprocess_cli.py | 70 +++++---- tests/test_transport.py | 133 ++++++++++++++++++ 2 files changed, 167 insertions(+), 36 deletions(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 26bd2ec..c7c7420 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -65,6 +65,7 @@ class SubprocessCLITransport(Transport): else _DEFAULT_MAX_BUFFER_SIZE ) self._temp_files: list[str] = [] # Track temporary files for cleanup + self._write_lock: anyio.Lock = anyio.Lock() def _find_cli(self) -> str: """Find Claude Code CLI binary.""" @@ -471,8 +472,6 @@ class SubprocessCLITransport(Transport): async def close(self) -> None: """Close the transport and clean up resources.""" - self._ready = False - # Clean up temporary files first (before early return) for temp_file in self._temp_files: with suppress(Exception): @@ -480,6 +479,7 @@ class SubprocessCLITransport(Transport): self._temp_files.clear() if not self._process: + self._ready = False return # Close stderr task group if active @@ -489,21 +489,19 @@ class SubprocessCLITransport(Transport): await self._stderr_task_group.__aexit__(None, None, None) self._stderr_task_group = None - # Close streams - if self._stdin_stream: - with suppress(Exception): - await self._stdin_stream.aclose() - self._stdin_stream = None + # Close stdin stream (acquire lock to prevent race with concurrent writes) + async with self._write_lock: + self._ready = False # Set inside lock to prevent TOCTOU with write() + if self._stdin_stream: + with suppress(Exception): + await self._stdin_stream.aclose() + self._stdin_stream = None if self._stderr_stream: with suppress(Exception): await self._stderr_stream.aclose() self._stderr_stream = None - if self._process.stdin: - with suppress(Exception): - await self._process.stdin.aclose() - # Terminate and wait for process if self._process.returncode is None: with suppress(ProcessLookupError): @@ -521,37 +519,37 @@ class SubprocessCLITransport(Transport): async def write(self, data: str) -> None: """Write raw data to the transport.""" - # Check if ready (like TypeScript) - if not self._ready or not self._stdin_stream: - raise CLIConnectionError("ProcessTransport is not ready for writing") + async with self._write_lock: + # All checks inside lock to prevent TOCTOU races with close()/end_input() + if not self._ready or not self._stdin_stream: + raise CLIConnectionError("ProcessTransport is not ready for writing") - # Check if process is still alive (like TypeScript) - if self._process and self._process.returncode is not None: - raise CLIConnectionError( - f"Cannot write to terminated process (exit code: {self._process.returncode})" - ) + if self._process and self._process.returncode is not None: + raise CLIConnectionError( + f"Cannot write to terminated process (exit code: {self._process.returncode})" + ) - # Check for exit errors (like TypeScript) - if self._exit_error: - raise CLIConnectionError( - f"Cannot write to process that exited with error: {self._exit_error}" - ) from self._exit_error + if self._exit_error: + raise CLIConnectionError( + f"Cannot write to process that exited with error: {self._exit_error}" + ) from self._exit_error - try: - await self._stdin_stream.send(data) - except Exception as e: - self._ready = False # Mark as not ready (like TypeScript) - self._exit_error = CLIConnectionError( - f"Failed to write to process stdin: {e}" - ) - raise self._exit_error from e + try: + await self._stdin_stream.send(data) + except Exception as e: + self._ready = False + self._exit_error = CLIConnectionError( + f"Failed to write to process stdin: {e}" + ) + raise self._exit_error from e async def end_input(self) -> None: """End the input stream (close stdin).""" - if self._stdin_stream: - with suppress(Exception): - await self._stdin_stream.aclose() - self._stdin_stream = None + async with self._write_lock: + if self._stdin_stream: + with suppress(Exception): + await self._stdin_stream.aclose() + self._stdin_stream = None def read_messages(self) -> AsyncIterator[dict[str, Any]]: """Read and parse messages from the transport.""" diff --git a/tests/test_transport.py b/tests/test_transport.py index c634fc2..fe9b6b2 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -693,3 +693,136 @@ class TestSubprocessCLITransport: cmd = transport._build_command() assert "--tools" not in cmd + + def test_concurrent_writes_are_serialized(self): + """Test that concurrent write() calls are serialized by the lock. + + When parallel subagents invoke MCP tools, they trigger concurrent write() + calls. Without the _write_lock, trio raises BusyResourceError. + + Uses a real subprocess with the same stream setup as production: + process.stdin -> TextSendStream + """ + + async def _test(): + import sys + from subprocess import PIPE + + from anyio.streams.text import TextSendStream + + # Create a real subprocess that consumes stdin (cross-platform) + process = await anyio.open_process( + [sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + ) + + try: + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeAgentOptions(cli_path="/usr/bin/claude"), + ) + + # Same setup as production: TextSendStream wrapping process.stdin + transport._ready = True + transport._process = MagicMock(returncode=None) + transport._stdin_stream = TextSendStream(process.stdin) + + # Spawn concurrent writes - the lock should serialize them + num_writes = 10 + errors: list[Exception] = [] + + async def do_write(i: int): + try: + await transport.write(f'{{"msg": {i}}}\n') + except Exception as e: + errors.append(e) + + async with anyio.create_task_group() as tg: + for i in range(num_writes): + tg.start_soon(do_write, i) + + # All writes should succeed - the lock serializes them + assert len(errors) == 0, f"Got errors: {errors}" + finally: + process.terminate() + await process.wait() + + anyio.run(_test, backend="trio") + + def test_concurrent_writes_fail_without_lock(self): + """Verify that without the lock, concurrent writes cause BusyResourceError. + + Uses a real subprocess with the same stream setup as production. + """ + + async def _test(): + import sys + from contextlib import asynccontextmanager + from subprocess import PIPE + + from anyio.streams.text import TextSendStream + + # Create a real subprocess that consumes stdin (cross-platform) + process = await anyio.open_process( + [sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + ) + + try: + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeAgentOptions(cli_path="/usr/bin/claude"), + ) + + # Same setup as production + transport._ready = True + transport._process = MagicMock(returncode=None) + transport._stdin_stream = TextSendStream(process.stdin) + + # Replace lock with no-op to trigger the race condition + class NoOpLock: + @asynccontextmanager + async def __call__(self): + yield + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + transport._write_lock = NoOpLock() + + # Spawn concurrent writes - should fail without lock + num_writes = 10 + errors: list[Exception] = [] + + async def do_write(i: int): + try: + await transport.write(f'{{"msg": {i}}}\n') + except Exception as e: + errors.append(e) + + async with anyio.create_task_group() as tg: + for i in range(num_writes): + tg.start_soon(do_write, i) + + # Should have gotten errors due to concurrent access + assert len(errors) > 0, ( + "Expected errors from concurrent access, but got none" + ) + + # Check that at least one error mentions the concurrent access + error_strs = [str(e) for e in errors] + assert any("another task" in s for s in error_strs), ( + f"Expected 'another task' error, got: {error_strs}" + ) + finally: + process.terminate() + await process.wait() + + anyio.run(_test, backend="trio") From 6791efec9307d29fdbba0f3481e9219b4a6db835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=A8=E6=A2=A8=E6=9E=9C?= Date: Fri, 5 Dec 2025 06:28:24 +0800 Subject: [PATCH 06/32] fix: add McpServer runtime placeholder for Pydantic 2.12+ compatibility (#385) ## Summary - Add runtime placeholder for `McpServer` type to fix Pydantic 2.12+ compatibility - `McpServer` was only imported under `TYPE_CHECKING`, causing `PydanticUserError` at runtime Fixes #384 Co-authored-by: lyrica --- src/claude_agent_sdk/types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 391ff95..fa6ca35 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -10,6 +10,9 @@ from typing_extensions import NotRequired if TYPE_CHECKING: from mcp.server import Server as McpServer +else: + # Runtime placeholder for forward reference resolution in Pydantic 2.12+ + McpServer = Any # Permission modes PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] From 69a310cc3f0bbba71e3a945c10af70146fab8ce9 Mon Sep 17 00:00:00 2001 From: Ramazan Rakhmatullin <32195167+grumpygordon@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:28:36 +0300 Subject: [PATCH 07/32] fix: propagate CLI errors to pending control requests (#388) ## Summary When the CLI exits with an error (e.g., invalid session ID passed to `--resume`), signal all pending control requests immediately instead of waiting for the 60-second timeout. **The fix adds 4 lines** to the exception handler in `_read_messages`: ```python # Signal all pending control requests so they fail fast instead of timing out for request_id, event in list(self.pending_control_responses.items()): if request_id not in self.pending_control_results: self.pending_control_results[request_id] = e event.set() ``` ## Problem When the CLI exits with an error, the SDK's message reader catches it but doesn't notify pending control requests. This causes `initialize()` to wait for the full 60-second timeout even though the error is known within seconds. Example scenario: 1. User passes invalid session ID via `ClaudeAgentOptions(resume="invalid-id")` 2. CLI prints `No conversation found with session ID: xxx` and exits with code 1 3. SDK message reader catches the error after ~3 seconds 4. But `initialize()` keeps waiting for 60 seconds before timing out ## Solution The existing code at `_send_control_request` lines 361-362 already handles exceptions in results: ```python if isinstance(result, Exception): raise result ``` The fix simply signals all pending control events when an error occurs, allowing them to fail fast with the actual error instead of timing out. ## Test Plan - [ ] Test with invalid session ID - should fail fast (~3s) instead of timing out (60s) - [ ] Test normal flow still works - [ ] Test multiple pending requests all get signaled Fixes #387 --- src/claude_agent_sdk/_internal/query.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index e48995f..8f0ac19 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -214,6 +214,11 @@ class Query: raise # Re-raise to properly handle cancellation except Exception as e: logger.error(f"Fatal error in message reader: {e}") + # Signal all pending control requests so they fail fast instead of timing out + for request_id, event in list(self.pending_control_responses.items()): + if request_id not in self.pending_control_results: + self.pending_control_results[request_id] = e + event.set() # Put error in stream so iterators can handle it await self._message_send.send({"type": "error", "error": str(e)}) finally: From 1b3e35d14e1ec3cec6421cb9392e1bfed86c9c87 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 4 Dec 2025 23:09:33 +0000 Subject: [PATCH 08/32] chore: bump bundled CLI version to 2.0.59 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index b45467a..74aa1ba 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.58" +__cli_version__ = "2.0.59" From 00b5730be67d0a63cc59711c92f33a1921bf47f3 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 6 Dec 2025 00:10:43 +0000 Subject: [PATCH 09/32] chore: bump bundled CLI version to 2.0.60 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 74aa1ba..bf377f2 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.59" +__cli_version__ = "2.0.60" From cf6b85fc5d913deaf1453d6c075ec8b5194b6adc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:33:33 -0800 Subject: [PATCH 10/32] chore: release v0.1.13 (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.1.13 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` to 0.1.13 - Updated version in `src/claude_agent_sdk/_version.py` to 0.1.13 - Updated `CHANGELOG.md` with release notes ## Release Information - Published to PyPI: https://pypi.org/project/claude-agent-sdk/0.1.13/ - Bundled CLI version: 2.0.59 - Install with: `pip install claude-agent-sdk==0.1.13` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Ashwin Bhat Co-authored-by: Claude --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- src/claude_agent_sdk/_version.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8250a32..4f674a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.1.13 + +### Bug Fixes + +- **Faster error handling**: CLI errors (e.g., invalid session ID) now propagate to pending requests immediately instead of waiting for the 60-second timeout (#388) +- **Pydantic 2.12+ compatibility**: Fixed `PydanticUserError` caused by `McpServer` type only being imported under `TYPE_CHECKING` (#385) +- **Concurrent subagent writes**: Added write lock to prevent `BusyResourceError` when multiple subagents invoke MCP tools in parallel (#391) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.59 + ## 0.1.12 ### New Features diff --git a/pyproject.toml b/pyproject.toml index f231338..be5afb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.12" +version = "0.1.13" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index 4acf622..782d1c6 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.12" +__version__ = "0.1.13" From 562528621278486220496f437b33b90c34d5abc7 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Sat, 6 Dec 2025 16:52:09 -0800 Subject: [PATCH 11/32] fix: move fetch-depth to publish job and use claude-opus-4-5 for changelog (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetch-depth: 0 was on build-wheels but changelog generation happens in the publish job. Moved it to the correct location and upgraded the model for better changelog generation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b831b18..b8b7e93 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -68,8 +68,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch all history including tags (necessary for changelog generation) - name: Set up Python uses: actions/setup-python@v5 @@ -109,6 +107,7 @@ jobs: - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 # Fetch all history including tags for changelog generation - name: Set up Python uses: actions/setup-python@v5 @@ -189,6 +188,7 @@ jobs: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} claude_args: | + --model claude-opus-4-5 --allowedTools 'Bash(git add:*),Bash(git commit:*),Edit' - name: Push branch and create PR From db4a6f7c289139107fbb4737a7f9c38d2f1d836e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 7 Dec 2025 10:47:47 +0000 Subject: [PATCH 12/32] chore: bump bundled CLI version to 2.0.61 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index bf377f2..4cc007a 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.60" +__cli_version__ = "2.0.61" From ccff8ddf48f8ee5e06258b9f70984186b9f10a71 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 9 Dec 2025 02:12:13 +0000 Subject: [PATCH 13/32] chore: bump bundled CLI version to 2.0.62 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 4cc007a..4f74ef2 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.61" +__cli_version__ = "2.0.62" From b5447d999df597d52bd3153372a50d391faa18d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:22:21 -0800 Subject: [PATCH 14/32] chore: release v0.1.14 (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.1.14 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` to 0.1.14 - Updated version in `src/claude_agent_sdk/_version.py` to 0.1.14 - Updated `CHANGELOG.md` with release notes ## Release Information - Published to PyPI: https://pypi.org/project/claude-agent-sdk/0.1.14/ - Bundled CLI version: 2.0.62 - Install with: `pip install claude-agent-sdk==0.1.14` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/claude_agent_sdk/_version.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f674a6..32775ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.14 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.62 + ## 0.1.13 ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index be5afb6..5e39d17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.13" +version = "0.1.14" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index 782d1c6..b02a634 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.13" +__version__ = "0.1.14" From 4acfcc2d399f71647a4186c2e3948d624cc4e3be Mon Sep 17 00:00:00 2001 From: sarahdeaton Date: Tue, 9 Dec 2025 09:33:42 -0800 Subject: [PATCH 15/32] Add license and terms section to README. (#399) Add "License and terms" section to README clarifying that use of the SDK is governed by Anthropic's Commercial Terms of Service --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2111986..bcbe969 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,6 @@ The package is published to PyPI via the GitHub Actions workflow in `.github/wor The workflow tracks both the package version and the bundled CLI version separately, allowing you to release a new package version with an updated CLI without code changes. -## License +## License and terms -MIT +Use of this SDK is governed by Anthropic's [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms), including when you use it to power products and services that you make available to your own customers and end users, except to the extent a specific component or dependency is covered by a different license as indicated in that component's LICENSE file. From 53482d8955565197261a393859fd4813dd62cb2b Mon Sep 17 00:00:00 2001 From: Noah Zweben Date: Tue, 9 Dec 2025 10:16:03 -0800 Subject: [PATCH 16/32] feat: add file checkpointing and rewind_files support (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `enable_file_checkpointing` option to `ClaudeAgentOptions` - Add `rewind_files(user_message_id)` method to `ClaudeSDKClient` and `Query` - Add `SDKControlRewindFilesRequest` type for the control protocol - Set `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING` env var when enabled This adds Python SDK support for the file rewind feature from claude-cli-internal PR #11265. ## Test plan - [x] Verified imports work correctly - [x] Verified linting passes (`ruff check`) - [x] Verified existing tests still pass (106 passed, pre-existing failures unrelated to this change) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Co-authored-by: Ashwin Bhat --- src/claude_agent_sdk/_internal/query.py | 15 +++++++++++ .../_internal/transport/subprocess_cli.py | 4 +++ src/claude_agent_sdk/client.py | 27 +++++++++++++++++++ src/claude_agent_sdk/types.py | 10 +++++++ 4 files changed, 56 insertions(+) diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 8f0ac19..c30fc15 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -539,6 +539,21 @@ class Query: } ) + async def rewind_files(self, user_message_id: str) -> None: + """Rewind tracked files to their state at a specific user message. + + Requires file checkpointing to be enabled via the `enable_file_checkpointing` option. + + Args: + user_message_id: UUID of the user message to rewind to + """ + await self._send_control_request( + { + "subtype": "rewind_files", + "user_message_id": user_message_id, + } + ) + async def stream_input(self, stream: AsyncIterable[dict[str, Any]]) -> None: """Stream input messages to transport. diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index c7c7420..a4882db 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -384,6 +384,10 @@ class SubprocessCLITransport(Transport): "CLAUDE_AGENT_SDK_VERSION": __version__, } + # Enable file checkpointing if requested + if self._options.enable_file_checkpointing: + process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true" + if self._cwd: process_env["PWD"] = self._cwd diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index 742d7d6..2f74260 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -261,6 +261,33 @@ class ClaudeSDKClient: raise CLIConnectionError("Not connected. Call connect() first.") await self._query.set_model(model) + async def rewind_files(self, user_message_id: str) -> None: + """Rewind tracked files to their state at a specific user message. + + Requires file checkpointing to be enabled via the `enable_file_checkpointing` option + when creating the ClaudeSDKClient. + + Args: + user_message_id: UUID of the user message to rewind to. This should be + the `uuid` field from a `UserMessage` received during the conversation. + + Example: + ```python + options = ClaudeAgentOptions(enable_file_checkpointing=True) + async with ClaudeSDKClient(options) as client: + await client.query("Make some changes to my files") + async for msg in client.receive_response(): + if isinstance(msg, UserMessage): + checkpoint_id = msg.uuid # Save this for later + + # Later, rewind to that point + await client.rewind_files(checkpoint_id) + ``` + """ + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + await self._query.rewind_files(user_message_id) + async def get_server_info(self) -> dict[str, Any] | None: """Get server initialization info including available commands and output styles. diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index fa6ca35..6d71322 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -673,6 +673,10 @@ class ClaudeAgentOptions: # Output format for structured outputs (matches Messages API structure) # Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}} output_format: dict[str, Any] | None = None + # Enable file checkpointing to track file changes during the session. + # When enabled, files can be rewound to their state at any user message + # using `ClaudeSDKClient.rewind_files()`. + enable_file_checkpointing: bool = False # SDK Control Protocol @@ -713,6 +717,11 @@ class SDKControlMcpMessageRequest(TypedDict): message: Any +class SDKControlRewindFilesRequest(TypedDict): + subtype: Literal["rewind_files"] + user_message_id: str + + class SDKControlRequest(TypedDict): type: Literal["control_request"] request_id: str @@ -723,6 +732,7 @@ class SDKControlRequest(TypedDict): | SDKControlSetPermissionModeRequest | SDKHookCallbackRequest | SDKControlMcpMessageRequest + | SDKControlRewindFilesRequest ) From 5b912962e200fbd37b8c872b443c57abfb05085d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:53:45 -0800 Subject: [PATCH 17/32] chore: release v0.1.15 (#408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.1.15 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` to 0.1.15 - Updated version in `src/claude_agent_sdk/_version.py` to 0.1.15 - Updated `CHANGELOG.md` with release notes ## Release Information - Published to PyPI: https://pypi.org/project/claude-agent-sdk/0.1.15/ - Bundled CLI version: 2.0.62 - Install with: `pip install claude-agent-sdk==0.1.15` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/claude_agent_sdk/_version.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32775ae..f492776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.15 + +### New Features + +- **File checkpointing and rewind**: Added `enable_file_checkpointing` option to `ClaudeAgentOptions` and `rewind_files(user_message_id)` method to `ClaudeSDKClient` and `Query`. This enables reverting file changes made during a session back to a specific checkpoint, useful for exploring different approaches or recovering from unwanted modifications (#395) + +### Documentation + +- Added license and terms section to README (#399) + ## 0.1.14 ### Internal/Other Changes diff --git a/pyproject.toml b/pyproject.toml index 5e39d17..10b0bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.14" +version = "0.1.15" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index b02a634..d9b37ab 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.14" +__version__ = "0.1.15" From 3cbb9e56be1f5b947d640f0b05710d7d032781be Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Fri, 12 Dec 2025 02:55:01 +0800 Subject: [PATCH 18/32] fix: parse error field in AssistantMessage to enable rate limit detection (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #401 Enables applications to detect API errors (especially `rate_limit` errors) by properly parsing the `error` field in `AssistantMessage`. ## Problem The SDK defines `AssistantMessage.error` (including `"rate_limit"`), but the message parser never extracted this field from the CLI response. This made it impossible for applications to: - Detect when rate limits are hit - Implement retry logic - Handle other API errors gracefully ## Solution Added error field extraction in the message parser: ```python return AssistantMessage( content=content_blocks, model=data["message"]["model"], parent_tool_use_id=data.get("parent_tool_use_id"), error=data["message"].get("error"), # ← Now extracts error field ) ``` ## Changes **Modified: `src/claude_agent_sdk/_internal/message_parser.py`** The parser now extracts the `error` field from API responses and populates it in the `AssistantMessage` object. ## Usage Example Applications can now detect and handle rate limits: ```python async for message in client.receive_response(): if isinstance(message, AssistantMessage): if message.error == "rate_limit": print("Rate limit hit! Implementing backoff...") await asyncio.sleep(60) # Retry logic here elif message.error: print(f"API error: {message.error}") ``` ## Testing - ✅ Passed ruff linting and formatting - ✅ Passed mypy type checking - ✅ All existing tests pass ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) ## Impact This fix enables production applications to: - Implement proper error handling for API errors - Build robust retry logic for rate limits - Provide better user feedback when errors occur - Avoid silent failures when the API returns errors --- 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy Co-authored-by: Claude Co-authored-by: Happy --- src/claude_agent_sdk/_internal/message_parser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 694c52c..312e4c0 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -120,6 +120,7 @@ def parse_message(data: dict[str, Any]) -> Message: content=content_blocks, model=data["message"]["model"], parent_tool_use_id=data.get("parent_tool_use_id"), + error=data["message"].get("error"), ) except KeyError as e: raise MessageParseError( From d2b3477a4e527d4e3934bda91bcd93392e432d0b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 12 Dec 2025 23:32:48 +0000 Subject: [PATCH 19/32] chore: bump bundled CLI version to 2.0.68 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 4f74ef2..06930ab 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.62" +__cli_version__ = "2.0.68" From a1c338726f25f887cfed2e45eea7bd5049ea9cbf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:26:36 -0800 Subject: [PATCH 20/32] chore: release v0.1.16 (#412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.1.16 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` to 0.1.16 - Updated version in `src/claude_agent_sdk/_version.py` to 0.1.16 - Updated `CHANGELOG.md` with release notes ## Release Information - Published to PyPI: https://pypi.org/project/claude-agent-sdk/0.1.16/ - Bundled CLI version: 2.0.68 - Install with: `pip install claude-agent-sdk==0.1.16` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/claude_agent_sdk/_version.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f492776..e252c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.16 + +### Bug Fixes + +- **Rate limit detection**: Fixed parsing of the `error` field in `AssistantMessage`, enabling applications to detect and handle API errors like rate limits. Previously, the `error` field was defined but never populated from CLI responses (#405) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.68 + ## 0.1.15 ### New Features diff --git a/pyproject.toml b/pyproject.toml index 10b0bad..84f6172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.15" +version = "0.1.16" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index d9b37ab..4fd71fa 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.15" +__version__ = "0.1.16" From f834ba9e1586ea2e31353fafcb41f78b7b9eab51 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 13 Dec 2025 01:00:44 +0000 Subject: [PATCH 21/32] chore: bump bundled CLI version to 2.0.69 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 06930ab..079e323 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.68" +__cli_version__ = "2.0.69" From 0ae5c3285c0f9b58c3430d609ed839701f9b8874 Mon Sep 17 00:00:00 2001 From: Noah Zweben Date: Mon, 15 Dec 2025 09:03:58 -0800 Subject: [PATCH 22/32] Add UUID to UserMessage response type to improve devX for rewind (#418) --- src/claude_agent_sdk/_internal/message_parser.py | 3 +++ src/claude_agent_sdk/client.py | 13 +++++++++---- src/claude_agent_sdk/types.py | 1 + tests/test_message_parser.py | 15 +++++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 312e4c0..4bfe814 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -48,6 +48,7 @@ def parse_message(data: dict[str, Any]) -> Message: case "user": try: parent_tool_use_id = data.get("parent_tool_use_id") + uuid = data.get("uuid") if isinstance(data["message"]["content"], list): user_content_blocks: list[ContentBlock] = [] for block in data["message"]["content"]: @@ -74,10 +75,12 @@ def parse_message(data: dict[str, Any]) -> Message: ) return UserMessage( content=user_content_blocks, + uuid=uuid, parent_tool_use_id=parent_tool_use_id, ) return UserMessage( content=data["message"]["content"], + uuid=uuid, parent_tool_use_id=parent_tool_use_id, ) except KeyError as e: diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index 2f74260..18ab818 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -264,8 +264,10 @@ class ClaudeSDKClient: async def rewind_files(self, user_message_id: str) -> None: """Rewind tracked files to their state at a specific user message. - Requires file checkpointing to be enabled via the `enable_file_checkpointing` option - when creating the ClaudeSDKClient. + Requires: + - `enable_file_checkpointing=True` to track file changes + - `extra_args={"replay-user-messages": None}` to receive UserMessage + objects with `uuid` in the response stream Args: user_message_id: UUID of the user message to rewind to. This should be @@ -273,11 +275,14 @@ class ClaudeSDKClient: Example: ```python - options = ClaudeAgentOptions(enable_file_checkpointing=True) + options = ClaudeAgentOptions( + enable_file_checkpointing=True, + extra_args={"replay-user-messages": None}, + ) async with ClaudeSDKClient(options) as client: await client.query("Make some changes to my files") async for msg in client.receive_response(): - if isinstance(msg, UserMessage): + if isinstance(msg, UserMessage) and msg.uuid: checkpoint_id = msg.uuid # Save this for later # Later, rewind to that point diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 6d71322..9c09345 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -562,6 +562,7 @@ class UserMessage: """User message.""" content: str | list[ContentBlock] + uuid: str | None = None parent_tool_use_id: str | None = None diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 60bcc53..cd18952 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -31,6 +31,21 @@ class TestMessageParser: assert isinstance(message.content[0], TextBlock) assert message.content[0].text == "Hello" + def test_parse_user_message_with_uuid(self): + """Test parsing a user message with uuid field (issue #414). + + The uuid field is needed for file checkpointing with rewind_files(). + """ + data = { + "type": "user", + "uuid": "msg-abc123-def456", + "message": {"content": [{"type": "text", "text": "Hello"}]}, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert message.uuid == "msg-abc123-def456" + assert len(message.content) == 1 + def test_parse_user_message_with_tool_use(self): """Test parsing a user message with tool_use block.""" data = { From 5752f38834373998800d58f10f745716d76b6102 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 15 Dec 2025 23:52:59 +0000 Subject: [PATCH 23/32] chore: bump bundled CLI version to 2.0.70 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 079e323..6309e4c 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.69" +__cli_version__ = "2.0.70" From eba5675328703d47aa6210d6341a2fff9b06c43b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:41:49 -0800 Subject: [PATCH 24/32] chore: release v0.1.17 (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.1.17 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` to 0.1.17 - Updated version in `src/claude_agent_sdk/_version.py` to 0.1.17 - Updated `CHANGELOG.md` with release notes ## Release Information - Published to PyPI: https://pypi.org/project/claude-agent-sdk/0.1.17/ - Bundled CLI version: 2.0.70 - Install with: `pip install claude-agent-sdk==0.1.17` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/claude_agent_sdk/_version.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e252c83..0b0ad22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.17 + +### New Features + +- **UserMessage UUID field**: Added `uuid` field to `UserMessage` response type, making it easier to use the `rewind_files()` method by providing direct access to message identifiers needed for file checkpointing (#418) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.70 + ## 0.1.16 ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 84f6172..0f3c76c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.16" +version = "0.1.17" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index 4fd71fa..e60bfef 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.16" +__version__ = "0.1.17" From 904c2ec33cc3339b480f47408b63771f57e521a3 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Mon, 15 Dec 2025 16:53:23 -0800 Subject: [PATCH 25/32] chore: use CHANGELOG.md content for GitHub release notes (#420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace auto-generated release notes with content extracted from CHANGELOG.md for the specific version being released. This provides more structured and consistent release notes with proper sections like Bug Fixes, New Features, etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .github/workflows/create-release-tag.yml | 46 +++++++++++++++--------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml index 8d6b8e1..47f6c82 100644 --- a/.github/workflows/create-release-tag.yml +++ b/.github/workflows/create-release-tag.yml @@ -24,12 +24,6 @@ jobs: VERSION="${BRANCH_NAME#release/v}" echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Get previous release tag - id: previous_tag - run: | - PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") - echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT - - name: Create and push tag run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" @@ -46,14 +40,34 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Create release with auto-generated notes - gh release create "v${{ steps.extract_version.outputs.version }}" \ - --title "Release v${{ steps.extract_version.outputs.version }}" \ - --generate-notes \ - --notes-start-tag "${{ steps.previous_tag.outputs.previous_tag }}" \ - --notes "Published to PyPI: https://pypi.org/project/claude-agent-sdk/${{ steps.extract_version.outputs.version }}/ + VERSION="${{ steps.extract_version.outputs.version }}" - ### Installation - \`\`\`bash - pip install claude-agent-sdk==${{ steps.extract_version.outputs.version }} - \`\`\`" + # Extract changelog section for this version to a temp file + awk -v ver="$VERSION" ' + /^## / { + if (found) exit + if ($2 == ver) found=1 + next + } + found { print } + ' CHANGELOG.md > release_notes.md + + # Append install instructions + cat >> release_notes.md << 'EOF' + +--- + +**PyPI:** https://pypi.org/project/claude-agent-sdk/VERSION/ + +```bash +pip install claude-agent-sdk==VERSION +``` +EOF + + # Replace VERSION placeholder + sed -i "s/VERSION/$VERSION/g" release_notes.md + + # Create release with notes from file + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file release_notes.md From a0ce44a3fabbc714df7a559a2855694569fe9585 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 16 Dec 2025 10:53:13 -0800 Subject: [PATCH 26/32] Add Docker-based test infrastructure for e2e tests (#424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `Dockerfile.test`: Python 3.12 image with Claude Code CLI installed - Add `scripts/test-docker.sh`: Local script to run tests in Docker - Add `test-e2e-docker` job to CI workflow that runs the full e2e suite in a container - Add `.dockerignore` to speed up Docker builds ## Context This helps catch Docker-specific issues like #406 where filesystem-based agents loaded via `setting_sources=["project"]` may silently fail in Docker environments. ## Local Usage ```bash # Run unit tests in Docker (no API key needed) ./scripts/test-docker.sh unit # Run e2e tests in Docker ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh e2e # Run all tests ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh all ``` ## Test plan - [x] Unit tests pass in Docker locally (129 passed) - [ ] CI job runs successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude --- .claude/agents/test-agent.md | 9 +++ .dockerignore | 49 ++++++++++++ .github/workflows/test.yml | 18 +++++ Dockerfile.test | 29 +++++++ e2e-tests/test_agents_and_settings.py | 111 +++++++++++++++++++++----- examples/filesystem_agents.py | 107 +++++++++++++++++++++++++ scripts/test-docker.sh | 77 ++++++++++++++++++ 7 files changed, 381 insertions(+), 19 deletions(-) create mode 100644 .claude/agents/test-agent.md create mode 100644 .dockerignore create mode 100644 Dockerfile.test create mode 100644 examples/filesystem_agents.py create mode 100755 scripts/test-docker.sh diff --git a/.claude/agents/test-agent.md b/.claude/agents/test-agent.md new file mode 100644 index 0000000..6515827 --- /dev/null +++ b/.claude/agents/test-agent.md @@ -0,0 +1,9 @@ +--- +name: test-agent +description: A simple test agent for SDK testing +tools: Read +--- + +# Test Agent + +You are a simple test agent. When asked a question, provide a brief, helpful answer. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d013f1b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing/Coverage +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ + +# Misc +*.log +.DS_Store diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d78d425..d581a8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,24 @@ jobs: run: | python -m pytest e2e-tests/ -v -m e2e + test-e2e-docker: + runs-on: ubuntu-latest + needs: test # Run after unit tests pass + # Run e2e tests in Docker to catch container-specific issues like #406 + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker test image + run: docker build -f Dockerfile.test -t claude-sdk-test . + + - name: Run e2e tests in Docker + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + test-examples: runs-on: ubuntu-latest needs: test-e2e # Run after e2e tests diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..22adf2e --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,29 @@ +# Dockerfile for running SDK tests in a containerized environment +# This helps catch Docker-specific issues like #406 + +FROM python:3.12-slim + +# Install dependencies for Claude CLI and git (needed for some tests) +RUN apt-get update && apt-get install -y \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude Code CLI +RUN curl -fsSL https://claude.ai/install.sh | bash +ENV PATH="/root/.local/bin:$PATH" + +# Set up working directory +WORKDIR /app + +# Copy the SDK source +COPY . . + +# Install SDK with dev dependencies +RUN pip install -e ".[dev]" + +# Verify CLI installation +RUN claude -v + +# Default: run unit tests +CMD ["python", "-m", "pytest", "tests/", "-v"] diff --git a/e2e-tests/test_agents_and_settings.py b/e2e-tests/test_agents_and_settings.py index 6e04066..3f6fc80 100644 --- a/e2e-tests/test_agents_and_settings.py +++ b/e2e-tests/test_agents_and_settings.py @@ -38,15 +38,88 @@ async def test_agent_definition(): async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": agents = message.data.get("agents", []) - assert isinstance( - agents, list - ), f"agents should be a list of strings, got: {type(agents)}" - assert ( - "test-agent" in agents - ), f"test-agent should be available, got: {agents}" + assert isinstance(agents, list), ( + f"agents should be a list of strings, got: {type(agents)}" + ) + assert "test-agent" in agents, ( + f"test-agent should be available, got: {agents}" + ) break +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_filesystem_agent_loading(): + """Test that filesystem-based agents load via setting_sources and produce full response. + + This is the core test for issue #406. It verifies that when using + setting_sources=["project"] with a .claude/agents/ directory containing + agent definitions, the SDK: + 1. Loads the agents (they appear in init message) + 2. Produces a full response with AssistantMessage + 3. Completes with a ResultMessage + + The bug in #406 causes the iterator to complete after only the + init SystemMessage, never yielding AssistantMessage or ResultMessage. + """ + with tempfile.TemporaryDirectory() as tmpdir: + # Create a temporary project with a filesystem agent + project_dir = Path(tmpdir) + agents_dir = project_dir / ".claude" / "agents" + agents_dir.mkdir(parents=True) + + # Create a test agent file + agent_file = agents_dir / "fs-test-agent.md" + agent_file.write_text( + """--- +name: fs-test-agent +description: A filesystem test agent for SDK testing +tools: Read +--- + +# Filesystem Test Agent + +You are a simple test agent. When asked a question, provide a brief, helpful answer. +""" + ) + + options = ClaudeAgentOptions( + setting_sources=["project"], + cwd=project_dir, + max_turns=1, + ) + + messages = [] + async with ClaudeSDKClient(options=options) as client: + await client.query("Say hello in exactly 3 words") + async for msg in client.receive_response(): + messages.append(msg) + + # Must have at least init, assistant, result + message_types = [type(m).__name__ for m in messages] + + assert "SystemMessage" in message_types, "Missing SystemMessage (init)" + assert "AssistantMessage" in message_types, ( + f"Missing AssistantMessage - got only: {message_types}. " + "This may indicate issue #406 (silent failure with filesystem agents)." + ) + assert "ResultMessage" in message_types, "Missing ResultMessage" + + # Find the init message and check for the filesystem agent + for msg in messages: + if isinstance(msg, SystemMessage) and msg.subtype == "init": + agents = msg.data.get("agents", []) + # Agents are returned as strings (just names) + assert "fs-test-agent" in agents, ( + f"fs-test-agent not loaded from filesystem. Found: {agents}" + ) + break + + # On Windows, wait for file handles to be released before cleanup + if sys.platform == "win32": + await asyncio.sleep(0.5) + + @pytest.mark.e2e @pytest.mark.asyncio async def test_setting_sources_default(): @@ -74,12 +147,12 @@ async def test_setting_sources_default(): async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": output_style = message.data.get("output_style") - assert ( - output_style != "local-test-style" - ), f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}" - assert ( - output_style == "default" - ), f"outputStyle should be 'default', got: {output_style}" + assert output_style != "local-test-style", ( + f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}" + ) + assert output_style == "default", ( + f"outputStyle should be 'default', got: {output_style}" + ) break # On Windows, wait for file handles to be released before cleanup @@ -121,9 +194,9 @@ This is a test command. async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": commands = message.data.get("slash_commands", []) - assert ( - "testcmd" not in commands - ), f"testcmd should NOT be available with user-only sources, got: {commands}" + assert "testcmd" not in commands, ( + f"testcmd should NOT be available with user-only sources, got: {commands}" + ) break # On Windows, wait for file handles to be released before cleanup @@ -159,11 +232,11 @@ async def test_setting_sources_project_included(): async for message in client.receive_response(): if isinstance(message, SystemMessage) and message.subtype == "init": output_style = message.data.get("output_style") - assert ( - output_style == "local-test-style" - ), f"outputStyle should be from local settings, got: {output_style}" + assert output_style == "local-test-style", ( + f"outputStyle should be from local settings, got: {output_style}" + ) break # On Windows, wait for file handles to be released before cleanup if sys.platform == "win32": - await asyncio.sleep(0.5) \ No newline at end of file + await asyncio.sleep(0.5) diff --git a/examples/filesystem_agents.py b/examples/filesystem_agents.py new file mode 100644 index 0000000..e5f6904 --- /dev/null +++ b/examples/filesystem_agents.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Example of loading filesystem-based agents via setting_sources. + +This example demonstrates how to load agents defined in .claude/agents/ files +using the setting_sources option. This is different from inline AgentDefinition +objects - these agents are loaded from markdown files on disk. + +This example tests the scenario from issue #406 where filesystem-based agents +loaded via setting_sources=["project"] may silently fail in certain environments. + +Usage: +./examples/filesystem_agents.py +""" + +import asyncio +from pathlib import Path + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ResultMessage, + SystemMessage, + TextBlock, +) + + +def extract_agents(msg: SystemMessage) -> list[str]: + """Extract agent names from system message init data.""" + if msg.subtype == "init": + agents = msg.data.get("agents", []) + # Agents can be either strings or dicts with a 'name' field + result = [] + for a in agents: + if isinstance(a, str): + result.append(a) + elif isinstance(a, dict): + result.append(a.get("name", "")) + return result + return [] + + +async def main(): + """Test loading filesystem-based agents.""" + print("=== Filesystem Agents Example ===") + print("Testing: setting_sources=['project'] with .claude/agents/test-agent.md") + print() + + # Use the SDK repo directory which has .claude/agents/test-agent.md + sdk_dir = Path(__file__).parent.parent + + options = ClaudeAgentOptions( + setting_sources=["project"], + cwd=sdk_dir, + ) + + message_types: list[str] = [] + agents_found: list[str] = [] + + async with ClaudeSDKClient(options=options) as client: + await client.query("Say hello in exactly 3 words") + + async for msg in client.receive_response(): + message_types.append(type(msg).__name__) + + if isinstance(msg, SystemMessage) and msg.subtype == "init": + agents_found = extract_agents(msg) + print(f"Init message received. Agents loaded: {agents_found}") + + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Assistant: {block.text}") + + elif isinstance(msg, ResultMessage): + print( + f"Result: subtype={msg.subtype}, cost=${msg.total_cost_usd or 0:.4f}" + ) + + print() + print("=== Summary ===") + print(f"Message types received: {message_types}") + print(f"Total messages: {len(message_types)}") + + # Validate the results + has_init = "SystemMessage" in message_types + has_assistant = "AssistantMessage" in message_types + has_result = "ResultMessage" in message_types + has_test_agent = "test-agent" in agents_found + + print() + if has_init and has_assistant and has_result: + print("SUCCESS: Received full response (init, assistant, result)") + else: + print("FAILURE: Did not receive full response") + print(f" - Init: {has_init}") + print(f" - Assistant: {has_assistant}") + print(f" - Result: {has_result}") + + if has_test_agent: + print("SUCCESS: test-agent was loaded from filesystem") + else: + print("WARNING: test-agent was NOT loaded (may not exist in .claude/agents/)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test-docker.sh b/scripts/test-docker.sh new file mode 100755 index 0000000..2cf9889 --- /dev/null +++ b/scripts/test-docker.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Run SDK tests in a Docker container +# This helps catch Docker-specific issues like #406 +# +# Usage: +# ./scripts/test-docker.sh [unit|e2e|all] +# +# Examples: +# ./scripts/test-docker.sh unit # Run unit tests only +# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh e2e # Run e2e tests +# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh all # Run all tests + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +usage() { + echo "Usage: $0 [unit|e2e|all]" + echo "" + echo "Commands:" + echo " unit - Run unit tests only (no API key needed)" + echo " e2e - Run e2e tests (requires ANTHROPIC_API_KEY)" + echo " all - Run both unit and e2e tests" + echo "" + echo "Examples:" + echo " $0 unit" + echo " ANTHROPIC_API_KEY=sk-... $0 e2e" + exit 1 +} + +echo "Building Docker test image..." +docker build -f Dockerfile.test -t claude-sdk-test . + +case "${1:-unit}" in + unit) + echo "" + echo "Running unit tests in Docker..." + docker run --rm claude-sdk-test \ + python -m pytest tests/ -v + ;; + e2e) + if [ -z "$ANTHROPIC_API_KEY" ]; then + echo "Error: ANTHROPIC_API_KEY environment variable is required for e2e tests" + echo "" + echo "Usage: ANTHROPIC_API_KEY=sk-... $0 e2e" + exit 1 + fi + echo "" + echo "Running e2e tests in Docker..." + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + ;; + all) + echo "" + echo "Running unit tests in Docker..." + docker run --rm claude-sdk-test \ + python -m pytest tests/ -v + + echo "" + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "Running e2e tests in Docker..." + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + else + echo "Skipping e2e tests (ANTHROPIC_API_KEY not set)" + fi + ;; + *) + usage + ;; +esac + +echo "" +echo "Done!" From 27575ae2ca7460c6a0f9224350b1f2941704b89d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 16 Dec 2025 22:09:36 +0000 Subject: [PATCH 27/32] chore: bump bundled CLI version to 2.0.71 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 6309e4c..794eb56 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.70" +__cli_version__ = "2.0.71" From 91e65b1927f4d3ab586e6a6eb8ce2d3fc78a152d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 17 Dec 2025 21:59:10 +0000 Subject: [PATCH 28/32] chore: bump bundled CLI version to 2.0.72 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 794eb56..bcc7288 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.71" +__cli_version__ = "2.0.72" From a3df9441286782f2c0fd476aaa99e1233c677ad2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:18:44 -0800 Subject: [PATCH 29/32] chore: release v0.1.18 (#428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the version to 0.1.18 after publishing to PyPI. ## Changes - Updated version in `pyproject.toml` to 0.1.18 - Updated version in `src/claude_agent_sdk/_version.py` to 0.1.18 - Updated `CHANGELOG.md` with release notes ## Release Information - Published to PyPI: https://pypi.org/project/claude-agent-sdk/0.1.18/ - Bundled CLI version: 2.0.72 - Install with: `pip install claude-agent-sdk==0.1.18` 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/claude_agent_sdk/_version.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0ad22..bfade18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.18 + +### Internal/Other Changes + +- **Docker-based test infrastructure**: Added Docker support for running e2e tests in containerized environments, helping catch Docker-specific issues (#424) +- Updated bundled Claude CLI to version 2.0.72 + ## 0.1.17 ### New Features diff --git a/pyproject.toml b/pyproject.toml index 0f3c76c..9058f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "claude-agent-sdk" -version = "0.1.17" +version = "0.1.18" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index e60bfef..de9a16c 100644 --- a/src/claude_agent_sdk/_version.py +++ b/src/claude_agent_sdk/_version.py @@ -1,3 +1,3 @@ """Version information for claude-agent-sdk.""" -__version__ = "0.1.17" +__version__ = "0.1.18" From 04347495b8ff309fb384fbf73749cacc1121b619 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Wed, 17 Dec 2025 17:39:12 -0800 Subject: [PATCH 30/32] fix: resolve YAML syntax error in create-release-tag workflow (#429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace heredoc with echo statements to fix YAML parsing issue. The unindented heredoc content was breaking out of the literal block scalar, causing `---` to be interpreted as a YAML document separator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .github/workflows/create-release-tag.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml index 47f6c82..c50abab 100644 --- a/.github/workflows/create-release-tag.yml +++ b/.github/workflows/create-release-tag.yml @@ -53,16 +53,16 @@ jobs: ' CHANGELOG.md > release_notes.md # Append install instructions - cat >> release_notes.md << 'EOF' - ---- - -**PyPI:** https://pypi.org/project/claude-agent-sdk/VERSION/ - -```bash -pip install claude-agent-sdk==VERSION -``` -EOF + { + echo "" + echo "---" + echo "" + echo "**PyPI:** https://pypi.org/project/claude-agent-sdk/VERSION/" + echo "" + echo '```bash' + echo "pip install claude-agent-sdk==VERSION" + echo '```' + } >> release_notes.md # Replace VERSION placeholder sed -i "s/VERSION/$VERSION/g" release_notes.md From 57e8b6ecd54e4851e3111da4874bfcecec17c6a3 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 19 Dec 2025 00:16:21 +0000 Subject: [PATCH 31/32] chore: bump bundled CLI version to 2.0.73 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index bcc7288..c855c1b 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.72" +__cli_version__ = "2.0.73" From 3eb12c5a37f09f8fba65271cfbd6233ae100e0c7 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 19 Dec 2025 22:12:38 +0000 Subject: [PATCH 32/32] chore: bump bundled CLI version to 2.0.74 --- src/claude_agent_sdk/_cli_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index c855c1b..8e7a72d 100644 --- a/src/claude_agent_sdk/_cli_version.py +++ b/src/claude_agent_sdk/_cli_version.py @@ -1,3 +1,3 @@ """Bundled Claude Code CLI version.""" -__cli_version__ = "2.0.73" +__cli_version__ = "2.0.74"