Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions py/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,16 +411,17 @@ def test_litellm(session, version):
_run_tests(session, f"{INTEGRATION_DIR}/litellm/test_litellm.py", version=version)


# CLI bundling started in 0.1.10 - older versions require external Claude Code installation
# CLI bundling started in 0.1.10 - older versions require external Claude Code installation.
# 0.1.9-0.1.17 were deleted from PyPI, so the oldest matrix entry is 0.1.18.
CLAUDE_AGENT_SDK_VERSIONS = _get_matrix_versions("claude-agent-sdk")


@nox.session()
@nox.parametrize("version", CLAUDE_AGENT_SDK_VERSIONS, ids=CLAUDE_AGENT_SDK_VERSIONS)
def test_claude_agent_sdk(session, version):
_install_test_deps(session)
# Claude Agent SDK 0.1.10 calls Server.list_tools(), which MCP 2 removed.
constraint_group = "test-mcp-v1" if version == "0.1.10" else None
# Claude Agent SDK 0.1.18 calls Server.list_tools(), which MCP 2 removed.
constraint_group = "test-mcp-v1" if version == "0.1.18" else None
_install_matrix_dep(session, "claude-agent-sdk", version, constraint_group)
_run_tests(session, f"{INTEGRATION_DIR}/claude_agent_sdk/test_claude_agent_sdk.py", version=version)

Expand Down
4 changes: 3 additions & 1 deletion py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,9 @@ latest = "pipecat-ai==1.12.0"

[tool.braintrust.matrix.claude-agent-sdk]
latest = "claude-agent-sdk==0.2.168"
"0.1.10" = "claude-agent-sdk==0.1.10"
# The integration still supports >=0.1.10, but 0.1.9-0.1.17 were deleted from
# PyPI, so 0.1.18 is the oldest installable version we can test against.
"0.1.18" = "claude-agent-sdk==0.1.18"

[tool.braintrust.matrix.cursor-sdk]
# 1.0.25 fixes bridge callback tokens that could be parsed as CLI flags while
Expand Down
2 changes: 1 addition & 1 deletion py/scripts/session-weights.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"test_btx_openai(1.77.0)": 8,
"test_btx_openai(1.92.0)": 7,
"test_btx_openai(latest)": 11,
"test_claude_agent_sdk(0.1.10)": 10,
"test_claude_agent_sdk(0.1.18)": 10,
"test_claude_agent_sdk(latest)": 10,
"test_cli": 11,
"test_cohere(5.0.0)": 15,
Expand Down
2 changes: 2 additions & 0 deletions py/src/braintrust/integrations/claude_agent_sdk/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class MCPToolMetadataValues:
CLAUDE_AGENT_TASK_SPAN_NAME: Final[str] = "Claude Agent"
CLAUDE_AGENT_RUN_FAILED_ERROR: Final[str] = "Claude Agent run failed"
ANTHROPIC_MESSAGES_CREATE_SPAN_NAME: Final[str] = "anthropic.messages.create"
# Bundled Claude Code CLIs before 2.1 (e.g. 2.0.72 in SDK 0.1.18) emit subagent calls as "Task".
SUBAGENT_TOOL_NAMES: Final[frozenset[str]] = frozenset({"Agent", "Task"})

MCP_TOOL_PREFIX: Final[str] = "mcp__"
MCP_TOOL_NAME_DELIMITER: Final[str] = "__"
Expand Down
23 changes: 23 additions & 0 deletions py/src/braintrust/integrations/claude_agent_sdk/_test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
Expand Down Expand Up @@ -43,6 +44,11 @@ def _resolve_cassettes_dir() -> Path:

CASSETTES_DIR = _resolve_cassettes_dir()

# Timeouts for test synchronization waits. Replay is effectively instant;
# a live recording waits on real model calls.
REPLAY_WAIT_TIMEOUT_S = 1.0
RECORD_WAIT_TIMEOUT_S = 120.0


def get_record_mode() -> str:
"""Match VCR-like defaults for subprocess transport cassettes."""
Expand Down Expand Up @@ -227,6 +233,10 @@ def _sanitize_url_string(value: str) -> str:
)


def _is_result_message(message: Any) -> bool:
return isinstance(message, dict) and message.get("type") == "result"


def _is_mcp_tool_call(message: Any) -> bool:
request = message.get("request") if isinstance(message, dict) else None
mcp_message = request.get("message") if isinstance(request, dict) else None
Expand Down Expand Up @@ -334,6 +344,14 @@ async def close(self) -> None:
if self._recording:
assert self._delegate is not None
await self._delegate.close()
if not any(event["op"] == "read" and _is_result_message(event["payload"]) for event in self._events):
# A run that fails or hangs mid-recording would otherwise leave a
# truncated cassette that "once" mode replays from then on.
warnings.warn(
f"Not saving cassette {self._cassette_name}: the recording has no result message",
stacklevel=2,
)
return
payload = {
"sdk_version": getattr(claude_agent_sdk, "__version__", "unknown"),
"cassette_name": self._cassette_name,
Expand All @@ -347,6 +365,11 @@ async def close(self) -> None:
def is_ready(self) -> bool:
return self._ready

@property
def wait_timeout(self) -> float:
"""Timeout for waits on this transport's gates, sized for replay or live recording."""
return REPLAY_WAIT_TIMEOUT_S if self._should_replay() else RECORD_WAIT_TIMEOUT_S

async def wait_for_mcp_tool_call(self) -> None:
"""Wait until replay delivers a local MCP ``tools/call`` request."""
await self._mcp_tool_call_read.wait()
Expand Down
Loading
Loading