|
5 | 5 | the actual Claude Agent SDK. |
6 | 6 | """ |
7 | 7 |
|
| 8 | +import asyncio |
| 9 | +import gc |
| 10 | +import sys |
| 11 | +import types |
| 12 | + |
8 | 13 | import pytest |
9 | 14 |
|
10 | 15 | # Try to import the Claude Agent SDK - skip tests if not available |
|
19 | 24 | from braintrust import logger |
20 | 25 | from braintrust.span_types import SpanTypeAttribute |
21 | 26 | from braintrust.test_helpers import init_test_logger |
| 27 | +from braintrust.wrappers.claude_agent_sdk import setup_claude_agent_sdk |
22 | 28 | from braintrust.wrappers.claude_agent_sdk._wrapper import ( |
23 | 29 | _create_client_wrapper_class, |
24 | 30 | _create_tool_wrapper_class, |
@@ -292,3 +298,99 @@ class TestAutoInstrumentClaudeAgentSDK: |
292 | 298 | def test_auto_instrument_claude_agent_sdk(self): |
293 | 299 | """Test auto_instrument patches Claude Agent SDK and creates spans.""" |
294 | 300 | verify_autoinstrument_script("test_auto_claude_agent_sdk.py") |
| 301 | + |
| 302 | + |
| 303 | +class _FakeClaudeAgentOptions: |
| 304 | + def __init__(self, model, permission_mode=None): |
| 305 | + self.model = model |
| 306 | + self.permission_mode = permission_mode |
| 307 | + |
| 308 | + |
| 309 | +class _FakeMessage: |
| 310 | + def __init__(self, content): |
| 311 | + self.content = content |
| 312 | + |
| 313 | + |
| 314 | +class _FakeResultMessage: |
| 315 | + def __init__(self): |
| 316 | + self.usage = types.SimpleNamespace(input_tokens=1, output_tokens=1, cache_creation_input_tokens=0) |
| 317 | + self.num_turns = 1 |
| 318 | + self.session_id = "session-123" |
| 319 | + |
| 320 | + |
| 321 | +class _FakeClaudeSDKClient: |
| 322 | + def __init__(self, options): |
| 323 | + self.options = options |
| 324 | + self._prompt = None |
| 325 | + |
| 326 | + async def __aenter__(self): |
| 327 | + return self |
| 328 | + |
| 329 | + async def __aexit__(self, *args): |
| 330 | + return None |
| 331 | + |
| 332 | + async def query(self, prompt): |
| 333 | + self._prompt = prompt |
| 334 | + |
| 335 | + async def receive_response(self): |
| 336 | + yield _FakeMessage("Hello") |
| 337 | + await asyncio.sleep(0) |
| 338 | + yield _FakeResultMessage() |
| 339 | + |
| 340 | + |
| 341 | +def _install_fake_claude_sdk(monkeypatch): |
| 342 | + fake_module = types.ModuleType("claude_agent_sdk") |
| 343 | + fake_module.ClaudeSDKClient = _FakeClaudeSDKClient |
| 344 | + fake_module.ClaudeAgentOptions = _FakeClaudeAgentOptions |
| 345 | + fake_module.__dict__["SdkMcpTool"] = None |
| 346 | + fake_module.__dict__["tool"] = None |
| 347 | + monkeypatch.setitem(sys.modules, "claude_agent_sdk", fake_module) |
| 348 | + return fake_module |
| 349 | + |
| 350 | + |
| 351 | +@pytest.mark.asyncio |
| 352 | +async def test_setup_claude_agent_sdk_repro_import_before_setup(memory_logger, monkeypatch): |
| 353 | + """Regression test for https://github.com/braintrustdata/braintrust-sdk-python/issues/7.""" |
| 354 | + assert not memory_logger.pop() |
| 355 | + |
| 356 | + fake_sdk = _install_fake_claude_sdk(monkeypatch) |
| 357 | + consumer_module = types.ModuleType("test_issue7_repro_module") |
| 358 | + consumer_module.ClaudeSDKClient = fake_sdk.ClaudeSDKClient |
| 359 | + consumer_module.ClaudeAgentOptions = fake_sdk.ClaudeAgentOptions |
| 360 | + monkeypatch.setitem(sys.modules, consumer_module.__name__, consumer_module) |
| 361 | + |
| 362 | + # Mirror the reported import pattern: |
| 363 | + # from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions |
| 364 | + assert setup_claude_agent_sdk(project=PROJECT_NAME, api_key=logger.TEST_API_KEY) |
| 365 | + assert consumer_module.ClaudeSDKClient is not _FakeClaudeSDKClient |
| 366 | + |
| 367 | + loop_errors = [] |
| 368 | + received_types = [] |
| 369 | + |
| 370 | + async def main(): |
| 371 | + loop = asyncio.get_running_loop() |
| 372 | + loop.set_exception_handler(lambda loop, ctx: loop_errors.append(ctx.get("exception") or ctx.get("message"))) |
| 373 | + |
| 374 | + options = consumer_module.ClaudeAgentOptions( |
| 375 | + model="claude-sonnet-4-20250514", |
| 376 | + permission_mode="bypassPermissions", |
| 377 | + ) |
| 378 | + async with consumer_module.ClaudeSDKClient(options=options) as client: |
| 379 | + await client.query("Hello") |
| 380 | + async for message in client.receive_response(): |
| 381 | + received_types.append(type(message).__name__) |
| 382 | + |
| 383 | + await asyncio.sleep(0) |
| 384 | + gc.collect() |
| 385 | + await asyncio.sleep(0.01) |
| 386 | + |
| 387 | + await main() |
| 388 | + |
| 389 | + assert loop_errors == [] |
| 390 | + assert received_types == ["_FakeMessage", "_FakeResultMessage"] |
| 391 | + |
| 392 | + spans = memory_logger.pop() |
| 393 | + task_spans = [s for s in spans if s["span_attributes"]["type"] == SpanTypeAttribute.TASK] |
| 394 | + assert len(task_spans) == 1 |
| 395 | + assert task_spans[0]["span_attributes"]["name"] == "Claude Agent" |
| 396 | + assert task_spans[0]["input"] == "Hello" |
0 commit comments