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/create-release-tag.yml b/.github/workflows/create-release-tag.yml index 8d6b8e1..c50abab 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 + { + 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 + + # Create release with notes from file + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file release_notes.md 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 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/CHANGELOG.md b/CHANGELOG.md index 4a71177..bfade18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # 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 + +- **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 + +- **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 + +- **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 + +- Updated bundled Claude CLI to version 2.0.62 + +## 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 + +- **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/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/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. 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/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/pyproject.toml b/pyproject.toml index 2850005..9058f3e 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.18" description = "Python SDK for Claude Code" readme = "README.md" requires-python = ">=3.10" 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!" 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/_cli_version.py b/src/claude_agent_sdk/_cli_version.py index 0335a9f..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.57" +__cli_version__ = "2.0.74" diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 694c52c..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: @@ -120,6 +123,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( diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index e48995f..c30fc15 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: @@ -534,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 73c1b29..a4882db 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.""" @@ -185,6 +186,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)]) @@ -203,6 +216,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] @@ -368,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 @@ -456,8 +476,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): @@ -465,6 +483,7 @@ class SubprocessCLITransport(Transport): self._temp_files.clear() if not self._process: + self._ready = False return # Close stderr task group if active @@ -474,21 +493,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): @@ -506,37 +523,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/src/claude_agent_sdk/_version.py b/src/claude_agent_sdk/_version.py index c8c57ba..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.11" +__version__ = "0.1.18" diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index 742d7d6..18ab818 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -261,6 +261,38 @@ 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: + - `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 + the `uuid` field from a `UserMessage` received during the conversation. + + Example: + ```python + 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) and msg.uuid: + 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 f37fd3c..9c09345 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -10,10 +10,16 @@ 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"] +# 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"] @@ -26,6 +32,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.""" @@ -549,6 +562,7 @@ class UserMessage: """User message.""" content: str | list[ContentBlock] + uuid: str | None = None parent_tool_use_id: str | None = None @@ -603,6 +617,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) @@ -614,6 +629,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 @@ -657,6 +674,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 @@ -697,6 +718,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 @@ -707,6 +733,7 @@ class SDKControlRequest(TypedDict): | SDKControlSetPermissionModeRequest | SDKHookCallbackRequest | SDKControlMcpMessageRequest + | SDKControlRewindFilesRequest ) 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 = { diff --git a/tests/test_transport.py b/tests/test_transport.py index b834671..fe9b6b2 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -647,3 +647,182 @@ 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 + + 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")