Compare commits

..

No commits in common. "main" and "v0.1.15" have entirely different histories.

16 changed files with 42 additions and 470 deletions

View file

@ -1,9 +0,0 @@
---
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.

View file

@ -1,49 +0,0 @@
# 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

View file

@ -24,6 +24,12 @@ jobs:
VERSION="${BRANCH_NAME#release/v}" VERSION="${BRANCH_NAME#release/v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT 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 - name: Create and push tag
run: | run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.email "github-actions[bot]@users.noreply.github.com"
@ -40,34 +46,14 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
VERSION="${{ steps.extract_version.outputs.version }}" # 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 }}/
# Extract changelog section for this version to a temp file ### Installation
awk -v ver="$VERSION" ' \`\`\`bash
/^## / { pip install claude-agent-sdk==${{ steps.extract_version.outputs.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

View file

@ -81,24 +81,6 @@ jobs:
run: | run: |
python -m pytest e2e-tests/ -v -m e2e 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: test-examples:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test-e2e # Run after e2e tests needs: test-e2e # Run after e2e tests

View file

@ -1,32 +1,5 @@
# Changelog # 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 ## 0.1.15
### New Features ### New Features

View file

@ -1,29 +0,0 @@
# 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"]

View file

@ -38,88 +38,15 @@ async def test_agent_definition():
async for message in client.receive_response(): async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init": if isinstance(message, SystemMessage) and message.subtype == "init":
agents = message.data.get("agents", []) agents = message.data.get("agents", [])
assert isinstance(agents, list), ( assert isinstance(
f"agents should be a list of strings, got: {type(agents)}" agents, list
) ), f"agents should be a list of strings, got: {type(agents)}"
assert "test-agent" in agents, ( assert (
f"test-agent should be available, got: {agents}" "test-agent" in agents
) ), f"test-agent should be available, got: {agents}"
break 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.e2e
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_setting_sources_default(): async def test_setting_sources_default():
@ -147,12 +74,12 @@ async def test_setting_sources_default():
async for message in client.receive_response(): async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init": if isinstance(message, SystemMessage) and message.subtype == "init":
output_style = message.data.get("output_style") output_style = message.data.get("output_style")
assert output_style != "local-test-style", ( assert (
f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}" output_style != "local-test-style"
) ), f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}"
assert output_style == "default", ( assert (
f"outputStyle should be 'default', got: {output_style}" output_style == "default"
) ), f"outputStyle should be 'default', got: {output_style}"
break break
# On Windows, wait for file handles to be released before cleanup # On Windows, wait for file handles to be released before cleanup
@ -194,9 +121,9 @@ This is a test command.
async for message in client.receive_response(): async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init": if isinstance(message, SystemMessage) and message.subtype == "init":
commands = message.data.get("slash_commands", []) commands = message.data.get("slash_commands", [])
assert "testcmd" not in commands, ( assert (
f"testcmd should NOT be available with user-only sources, got: {commands}" "testcmd" not in commands
) ), f"testcmd should NOT be available with user-only sources, got: {commands}"
break break
# On Windows, wait for file handles to be released before cleanup # On Windows, wait for file handles to be released before cleanup
@ -232,9 +159,9 @@ async def test_setting_sources_project_included():
async for message in client.receive_response(): async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init": if isinstance(message, SystemMessage) and message.subtype == "init":
output_style = message.data.get("output_style") output_style = message.data.get("output_style")
assert output_style == "local-test-style", ( assert (
f"outputStyle should be from local settings, got: {output_style}" output_style == "local-test-style"
) ), f"outputStyle should be from local settings, got: {output_style}"
break break
# On Windows, wait for file handles to be released before cleanup # On Windows, wait for file handles to be released before cleanup

View file

@ -1,107 +0,0 @@
#!/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())

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "claude-agent-sdk" name = "claude-agent-sdk"
version = "0.1.18" version = "0.1.15"
description = "Python SDK for Claude Code" description = "Python SDK for Claude Code"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View file

@ -1,77 +0,0 @@
#!/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!"

View file

@ -1,3 +1,3 @@
"""Bundled Claude Code CLI version.""" """Bundled Claude Code CLI version."""
__cli_version__ = "2.0.74" __cli_version__ = "2.0.62"

View file

@ -48,7 +48,6 @@ def parse_message(data: dict[str, Any]) -> Message:
case "user": case "user":
try: try:
parent_tool_use_id = data.get("parent_tool_use_id") parent_tool_use_id = data.get("parent_tool_use_id")
uuid = data.get("uuid")
if isinstance(data["message"]["content"], list): if isinstance(data["message"]["content"], list):
user_content_blocks: list[ContentBlock] = [] user_content_blocks: list[ContentBlock] = []
for block in data["message"]["content"]: for block in data["message"]["content"]:
@ -75,12 +74,10 @@ def parse_message(data: dict[str, Any]) -> Message:
) )
return UserMessage( return UserMessage(
content=user_content_blocks, content=user_content_blocks,
uuid=uuid,
parent_tool_use_id=parent_tool_use_id, parent_tool_use_id=parent_tool_use_id,
) )
return UserMessage( return UserMessage(
content=data["message"]["content"], content=data["message"]["content"],
uuid=uuid,
parent_tool_use_id=parent_tool_use_id, parent_tool_use_id=parent_tool_use_id,
) )
except KeyError as e: except KeyError as e:
@ -123,7 +120,6 @@ def parse_message(data: dict[str, Any]) -> Message:
content=content_blocks, content=content_blocks,
model=data["message"]["model"], model=data["message"]["model"],
parent_tool_use_id=data.get("parent_tool_use_id"), parent_tool_use_id=data.get("parent_tool_use_id"),
error=data["message"].get("error"),
) )
except KeyError as e: except KeyError as e:
raise MessageParseError( raise MessageParseError(

View file

@ -1,3 +1,3 @@
"""Version information for claude-agent-sdk.""" """Version information for claude-agent-sdk."""
__version__ = "0.1.18" __version__ = "0.1.15"

View file

@ -264,10 +264,8 @@ class ClaudeSDKClient:
async def rewind_files(self, user_message_id: str) -> None: async def rewind_files(self, user_message_id: str) -> None:
"""Rewind tracked files to their state at a specific user message. """Rewind tracked files to their state at a specific user message.
Requires: Requires file checkpointing to be enabled via the `enable_file_checkpointing` option
- `enable_file_checkpointing=True` to track file changes when creating the ClaudeSDKClient.
- `extra_args={"replay-user-messages": None}` to receive UserMessage
objects with `uuid` in the response stream
Args: Args:
user_message_id: UUID of the user message to rewind to. This should be user_message_id: UUID of the user message to rewind to. This should be
@ -275,14 +273,11 @@ class ClaudeSDKClient:
Example: Example:
```python ```python
options = ClaudeAgentOptions( options = ClaudeAgentOptions(enable_file_checkpointing=True)
enable_file_checkpointing=True,
extra_args={"replay-user-messages": None},
)
async with ClaudeSDKClient(options) as client: async with ClaudeSDKClient(options) as client:
await client.query("Make some changes to my files") await client.query("Make some changes to my files")
async for msg in client.receive_response(): async for msg in client.receive_response():
if isinstance(msg, UserMessage) and msg.uuid: if isinstance(msg, UserMessage):
checkpoint_id = msg.uuid # Save this for later checkpoint_id = msg.uuid # Save this for later
# Later, rewind to that point # Later, rewind to that point

View file

@ -562,7 +562,6 @@ class UserMessage:
"""User message.""" """User message."""
content: str | list[ContentBlock] content: str | list[ContentBlock]
uuid: str | None = None
parent_tool_use_id: str | None = None parent_tool_use_id: str | None = None

View file

@ -31,21 +31,6 @@ class TestMessageParser:
assert isinstance(message.content[0], TextBlock) assert isinstance(message.content[0], TextBlock)
assert message.content[0].text == "Hello" 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): def test_parse_user_message_with_tool_use(self):
"""Test parsing a user message with tool_use block.""" """Test parsing a user message with tool_use block."""
data = { data = {