diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 755d7b47e..c206008b9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.7" + ".": "0.9.8" } diff --git a/.stats.yml b/.stats.yml index 3a7c990c0..ea7c6845a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 45 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp%2Fagentex-sdk-5fa3cb3c867281c804913c7c3e6d2143b5606d4924d42119f4b2b246f33e3db3.yml -openapi_spec_hash: 8ec711692f3ed7cd34a7a3b9d3e33f7c +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp%2Fagentex-sdk-484a34db630cbb844d4496b9eada50771ded02db3f8ef71ec5316ce14d5470e4.yml +openapi_spec_hash: 6a78e58d2b468cf3c5548d3fcd95d5c7 config_hash: fb079ef7936611b032568661b8165f19 diff --git a/CHANGELOG.md b/CHANGELOG.md index 64086aa8f..f2f15a6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.9.8 (2026-04-06) + +Full Changelog: [v0.9.7...v0.9.8](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.7...v0.9.8) + +### Features + +* **adk:** allow all ClaudeAgentOptions in run_claude_agent_activity ([e41aec7](https://github.com/scaleapi/scale-agentex-python/commit/e41aec738f230070c5db1dcbf7e08abc1ef538d9)) +* pass AGENTEX_DEPLOYMENT_ID in registration metadata ([#305](https://github.com/scaleapi/scale-agentex-python/issues/305)) ([31af8c6](https://github.com/scaleapi/scale-agentex-python/commit/31af8c6fc4aaafad57b70ded4883ced1254aeb1b)) +* **tracing:** Add background queue for async span processing ([#303](https://github.com/scaleapi/scale-agentex-python/issues/303)) ([3a60add](https://github.com/scaleapi/scale-agentex-python/commit/3a60add048ff24266a45700b4e78def8ffed3e0b)) + + +### Bug Fixes + +* **tracing:** Fix memory leak in SGP tracing processors ([#302](https://github.com/scaleapi/scale-agentex-python/issues/302)) ([f43dac4](https://github.com/scaleapi/scale-agentex-python/commit/f43dac4fa7ca7090b37c6c3bf285eb12515764bb)) + ## 0.9.7 (2026-03-30) Full Changelog: [v0.9.6...v0.9.7](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.6...v0.9.7) diff --git a/pyproject.toml b/pyproject.toml index 8f4841b98..edafdfa2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agentex-sdk" -version = "0.9.7" +version = "0.9.8" description = "The official Python library for the agentex API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/agentex/_version.py b/src/agentex/_version.py index 41b460e98..5b5ed90d3 100644 --- a/src/agentex/_version.py +++ b/src/agentex/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "agentex" -__version__ = "0.9.7" # x-release-please-version +__version__ = "0.9.8" # x-release-please-version diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py b/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py index 6f8a7c413..fd40545ec 100644 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py +++ b/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py @@ -40,6 +40,7 @@ create_streaming_hooks, ) from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + claude_options_to_dict, run_claude_agent_activity, create_workspace_directory, ) @@ -59,6 +60,7 @@ # Activities "run_claude_agent_activity", "create_workspace_directory", + "claude_options_to_dict", # Message handling "ClaudeMessageHandler", # Hooks diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py b/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py index ccd6a9f94..3e97d824e 100644 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py +++ b/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import dataclasses from typing import Any from temporalio import activity @@ -19,6 +20,65 @@ logger = make_logger(__name__) +# Fields that are not serializable across the Temporal boundary and should be +# excluded from claude_options_to_dict output. +_NON_SERIALIZABLE_FIELDS = {"debug_stderr", "stderr", "can_use_tool", "hooks"} + + +def claude_options_to_dict(options: ClaudeAgentOptions) -> dict[str, Any]: + """Convert a ClaudeAgentOptions to a Temporal-serializable dict. + + Use this at the workflow call site so you get full type safety and + autocomplete when constructing options, while Temporal gets a plain dict. + + Non-serializable fields (callbacks, file objects, hooks) are excluded — + the activity injects AgentEx streaming hooks automatically. + + Example:: + + extra = ClaudeAgentOptions( + mcp_servers={"my-server": McpServerConfig(command="npx", args=[...])}, + model="sonnet", + ) + + result = await workflow.execute_activity( + run_claude_agent_activity, + args=[prompt, workspace, tools, "acceptEdits", None, None, None, + claude_options_to_dict(extra)], + ... + ) + """ + result = {} + for field in dataclasses.fields(options): + if field.name in _NON_SERIALIZABLE_FIELDS: + continue + value = getattr(options, field.name) + # Skip fields left at their default to keep the dict minimal + if value == field.default or ( + callable(field.default_factory) and value == field.default_factory() # type: ignore[arg-type] + ): + continue + result[field.name] = value + return result + + +def _reconstruct_agent_defs(agents: dict[str, Any] | None) -> dict[str, AgentDefinition] | None: + """Reconstruct AgentDefinition objects from Temporal-serialized dicts.""" + if not agents: + return None + agent_defs = {} + for name, agent_data in agents.items(): + if isinstance(agent_data, AgentDefinition): + agent_defs[name] = agent_data + else: + agent_defs[name] = AgentDefinition( + description=agent_data.get('description', ''), + prompt=agent_data.get('prompt', ''), + tools=agent_data.get('tools'), + model=agent_data.get('model'), + ) + return agent_defs + @activity.defn async def create_workspace_directory(task_id: str, workspace_root: str | None = None) -> str: @@ -47,12 +107,13 @@ async def run_claude_agent_activity( prompt: str, workspace_path: str, allowed_tools: list[str], - permission_mode: str = "acceptEdits", + permission_mode: str | None = None, system_prompt: str | None = None, resume_session_id: str | None = None, agents: dict[str, Any] | None = None, + claude_options: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Execute Claude SDK - wrapped in Temporal activity + """Execute Claude SDK - wrapped in Temporal activity. This activity: 1. Gets task_id from ContextVar (set by ContextInterceptor) @@ -69,6 +130,11 @@ async def run_claude_agent_activity( system_prompt: Optional system prompt override resume_session_id: Optional session ID to resume conversation context agents: Optional dict of subagent definitions for Task tool + claude_options: Optional dict of additional ClaudeAgentOptions kwargs. + Any field supported by the Claude SDK can be passed here + (e.g. mcp_servers, model, max_turns, max_budget_usd, etc.). + These are merged with the explicit params above, with explicit + params taking precedence. Returns: dict with "messages", "session_id", "usage", and "cost_usd" keys @@ -88,38 +154,54 @@ async def run_claude_agent_activity( # Reconstruct AgentDefinition objects from serialized dicts # Temporal serializes dataclasses to dicts, need to recreate them - agent_defs = None - if agents: - agent_defs = {} - for name, agent_data in agents.items(): - if isinstance(agent_data, AgentDefinition): - agent_defs[name] = agent_data - else: - # Reconstruct from dict - agent_defs[name] = AgentDefinition( - description=agent_data.get('description', ''), - prompt=agent_data.get('prompt', ''), - tools=agent_data.get('tools'), - model=agent_data.get('model'), - ) + agent_defs = _reconstruct_agent_defs(agents) + + # Only include explicit params that were actually supplied (non-None), + # so claude_options values for system_prompt/resume/agents are not masked. + explicit_params: dict[str, Any] = {k: v for k, v in { + "cwd": workspace_path, + "allowed_tools": allowed_tools, + "permission_mode": permission_mode, + "system_prompt": system_prompt, + "resume": resume_session_id, + "agents": agent_defs, + }.items() if v is not None} + + # Merge in any additional claude_options (explicit params take precedence) + if claude_options: + claude_options = dict(claude_options) # avoid mutating caller's dict + if "agents" in claude_options: + claude_options["agents"] = _reconstruct_agent_defs(claude_options["agents"]) + options_dict = {**claude_options, **explicit_params} + else: + options_dict = explicit_params + + # Apply default for permission_mode if neither source supplied a value + if "permission_mode" not in options_dict: + options_dict["permission_mode"] = "acceptEdits" # Create hooks for streaming tool calls and subagent execution - hooks = create_streaming_hooks( + streaming_hooks = create_streaming_hooks( task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id, ) - # Configure Claude with workspace isolation, session resume, subagents, and hooks - options = ClaudeAgentOptions( - cwd=workspace_path, - allowed_tools=allowed_tools, - permission_mode=permission_mode, # type: ignore - system_prompt=system_prompt, - resume=resume_session_id, - agents=agent_defs, - hooks=hooks, # Tool lifecycle hooks for streaming! - ) + # Merge streaming hooks with any user-provided hooks from claude_options + user_hooks = options_dict.pop("hooks", None) + if user_hooks: + merged_hooks = dict(streaming_hooks) + for event, matchers in user_hooks.items(): + if event in merged_hooks: + merged_hooks[event] = merged_hooks[event] + matchers + else: + merged_hooks[event] = matchers + options_dict["hooks"] = merged_hooks + else: + options_dict["hooks"] = streaming_hooks + + # Construct ClaudeAgentOptions — any SDK field works via claude_options + options = ClaudeAgentOptions(**options_dict) # Create message handler for streaming handler = ClaudeMessageHandler( diff --git a/tests/lib/test_claude_agents_activities.py b/tests/lib/test_claude_agents_activities.py new file mode 100644 index 000000000..77d8eaf53 --- /dev/null +++ b/tests/lib/test_claude_agents_activities.py @@ -0,0 +1,458 @@ +"""Tests for Claude Agents SDK activity helpers. + +These tests validate the serialization helpers and activity behavior for the +Claude Agents SDK Temporal integration. The import chain for the activities +module transitively pulls in langchain_core and langgraph (via agentex.lib.adk), +which are optional deps not present in the base test venv. We mock the +problematic intermediate modules to break the chain. +""" + +from __future__ import annotations + +import sys + +# The activities module lives under agentex.lib.core.temporal.plugins.claude_agents. +# Importing it normally triggers plugins/__init__.py which imports the openai_agents +# plugin, which transitively imports langchain_core and langgraph (not installed in +# the base test environment). +# +# We use importlib.util to load *only* the activities module from its file path, +# bypassing all __init__.py chains. +import contextvars +import importlib.util +from types import ModuleType +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from claude_agent_sdk import HookMatcher, AgentDefinition, ClaudeAgentOptions + +_SRC = Path(__file__).resolve().parents[2] / "src" +_ACTIVITIES_PATH = ( + _SRC + / "agentex" + / "lib" + / "core" + / "temporal" + / "plugins" + / "claude_agents" + / "activities.py" +) + +# Stub the modules that activities.py imports (hooks, message_handler, interceptor) +_hooks_mock = MagicMock() +_handler_mock = MagicMock() +_interceptor_mock = MagicMock() +_interceptor_mock.streaming_task_id = contextvars.ContextVar("streaming_task_id", default=None) +_interceptor_mock.streaming_trace_id = contextvars.ContextVar("streaming_trace_id", default=None) +_interceptor_mock.streaming_parent_span_id = contextvars.ContextVar("streaming_parent_span_id", default=None) + +# Register stubs for all imports that activities.py does +_stubs = { + "agentex.lib.utils.logging": MagicMock(), + "agentex.lib.core.temporal.plugins.claude_agents.hooks": _hooks_mock, + "agentex.lib.core.temporal.plugins.claude_agents.message_handler": _handler_mock, + "agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor": _interceptor_mock, +} +for _name, _mock in _stubs.items(): + sys.modules.setdefault(_name, _mock) + +# Also ensure parent packages exist as stubs so Python resolves the dotted path +for _pkg in [ + "agentex.lib.core.temporal.plugins", + "agentex.lib.core.temporal.plugins.claude_agents", + "agentex.lib.core.temporal.plugins.openai_agents", + "agentex.lib.core.temporal.plugins.openai_agents.interceptors", +]: + if _pkg not in sys.modules: + _mod = ModuleType(_pkg) + _mod.__path__ = [] # type: ignore[attr-defined] + _mod.__package__ = _pkg + sys.modules[_pkg] = _mod + +# Load activities.py directly from its file path +_spec = importlib.util.spec_from_file_location( + "agentex.lib.core.temporal.plugins.claude_agents.activities", + _ACTIVITIES_PATH, +) +assert _spec is not None and _spec.loader is not None +_activities_mod = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = _activities_mod +_spec.loader.exec_module(_activities_mod) + +_reconstruct_agent_defs = _activities_mod._reconstruct_agent_defs # type: ignore[attr-defined] +claude_options_to_dict = _activities_mod.claude_options_to_dict # type: ignore[attr-defined] + + +class TestClaudeOptionsToDict: + """Tests for claude_options_to_dict serialization helper.""" + + def test_basic_fields(self): + options = ClaudeAgentOptions( + cwd="/workspace", + allowed_tools=["Read", "Write"], + permission_mode="acceptEdits", + system_prompt="Be helpful.", + ) + result = claude_options_to_dict(options) + assert result["cwd"] == "/workspace" + assert result["allowed_tools"] == ["Read", "Write"] + assert result["permission_mode"] == "acceptEdits" + assert result["system_prompt"] == "Be helpful." + + def test_excludes_defaults(self): + """Fields left at their default value should not appear in the dict.""" + options = ClaudeAgentOptions(cwd="/workspace") + result = claude_options_to_dict(options) + assert "cwd" in result + # These are all defaults and should be absent + assert "continue_conversation" not in result + assert "include_partial_messages" not in result + assert "fork_session" not in result + assert "disallowed_tools" not in result + + def test_excludes_non_serializable_fields(self): + """Callbacks and file objects should never appear in the dict.""" + options = ClaudeAgentOptions( + cwd="/workspace", + can_use_tool=lambda *_: True, + stderr=lambda msg: None, + ) + result = claude_options_to_dict(options) + assert "can_use_tool" not in result + assert "stderr" not in result + assert "debug_stderr" not in result + assert "hooks" not in result + + def test_mcp_servers_included(self): + options = ClaudeAgentOptions( + cwd="/workspace", + mcp_servers={"my-server": {"command": "npx", "args": ["server"]}}, + ) + result = claude_options_to_dict(options) + assert result["mcp_servers"] == {"my-server": {"command": "npx", "args": ["server"]}} + + def test_agents_included(self): + agents = { + "reviewer": AgentDefinition( + description="Code reviewer", + prompt="Review code.", + tools=["Read"], + model="sonnet", + ) + } + options = ClaudeAgentOptions(cwd="/workspace", agents=agents) + result = claude_options_to_dict(options) + assert "agents" in result + assert "reviewer" in result["agents"] + + def test_model_and_budget_fields(self): + options = ClaudeAgentOptions( + cwd="/workspace", + model="opus", + max_turns=5, + max_budget_usd=1.0, + max_thinking_tokens=8000, + ) + result = claude_options_to_dict(options) + assert result["model"] == "opus" + assert result["max_turns"] == 5 + assert result["max_budget_usd"] == 1.0 + assert result["max_thinking_tokens"] == 8000 + + def test_resume_session(self): + options = ClaudeAgentOptions( + cwd="/workspace", + resume="session-abc-123", + ) + result = claude_options_to_dict(options) + assert result["resume"] == "session-abc-123" + + def test_roundtrip_constructs_options(self): + """The dict produced by claude_options_to_dict can construct a new ClaudeAgentOptions.""" + original = ClaudeAgentOptions( + cwd="/workspace", + allowed_tools=["Read", "Bash"], + permission_mode="acceptEdits", + model="sonnet", + max_turns=3, + ) + d = claude_options_to_dict(original) + reconstructed = ClaudeAgentOptions(**d) + assert reconstructed.cwd == original.cwd + assert reconstructed.allowed_tools == original.allowed_tools + assert reconstructed.permission_mode == original.permission_mode + assert reconstructed.model == original.model + assert reconstructed.max_turns == original.max_turns + + +class TestReconstructAgentDefs: + """Tests for _reconstruct_agent_defs helper.""" + + def test_none_input(self): + assert _reconstruct_agent_defs(None) is None + + def test_empty_dict(self): + assert _reconstruct_agent_defs({}) is None + + def test_already_agent_definitions(self): + agent = AgentDefinition(description="test", prompt="test prompt") + result = _reconstruct_agent_defs({"a": agent}) + assert result is not None + assert result["a"] is agent + + def test_dict_input(self): + """Temporal serializes dataclasses to dicts - verify reconstruction.""" + raw = { + "reviewer": { + "description": "Code reviewer", + "prompt": "Review code.", + "tools": ["Read", "Grep"], + "model": "sonnet", + } + } + result = _reconstruct_agent_defs(raw) + assert result is not None + assert isinstance(result["reviewer"], AgentDefinition) + assert result["reviewer"].description == "Code reviewer" + assert result["reviewer"].prompt == "Review code." + assert result["reviewer"].tools == ["Read", "Grep"] + assert result["reviewer"].model == "sonnet" + + def test_mixed_input(self): + """Mix of already-constructed and dict-serialized agents.""" + existing = AgentDefinition(description="existing", prompt="p") + raw = {"description": "from_dict", "prompt": "p2", "tools": None, "model": None} + result = _reconstruct_agent_defs({"a": existing, "b": raw}) + assert result is not None + assert isinstance(result["a"], AgentDefinition) + assert isinstance(result["b"], AgentDefinition) + assert result["a"].description == "existing" + assert result["b"].description == "from_dict" + + +class TestRunClaudeAgentActivity: + """Tests for the run_claude_agent_activity Temporal activity.""" + + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeMessageHandler", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", + ) + async def test_passes_claude_options_to_sdk( + self, + mock_create_hooks, + mock_client_cls, + mock_handler_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """Verify that claude_options extras are merged into ClaudeAgentOptions.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + run_claude_agent_activity, + ) + + # Set up context vars + mock_task_id.get.return_value = "task-1" + mock_trace_id.get.return_value = "trace-1" + mock_parent_span_id.get.return_value = "span-1" + + # Set up hooks + mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": []} + + # Set up client as async context manager + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + # Set up handler (get_results is sync, so use MagicMock for it) + mock_handler = AsyncMock() + mock_handler.get_results = MagicMock(return_value={ + "messages": [], + "session_id": "sess-1", + "usage": {}, + "cost_usd": 0.0, + }) + mock_handler_cls.return_value = mock_handler + + # Extra SDK options passed via claude_options + extra = { + "model": "sonnet", + "mcp_servers": {"my-server": {"command": "npx", "args": ["srv"]}}, + } + + # activity.defn decorates in-place (no __wrapped__), call directly + await run_claude_agent_activity( + prompt="Hello", + workspace_path="/workspace", + allowed_tools=["Read"], + permission_mode="acceptEdits", + claude_options=extra, + ) + + # Verify ClaudeAgentOptions was constructed with both explicit + extra fields + call_args = mock_client_cls.call_args + options = call_args.kwargs.get("options") or call_args[1].get("options") + assert options.cwd == "/workspace" + assert options.allowed_tools == ["Read"] + assert options.model == "sonnet" + assert options.mcp_servers == {"my-server": {"command": "npx", "args": ["srv"]}} + + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeMessageHandler", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", + ) + async def test_claude_options_not_masked_by_none_explicit_params( + self, + mock_create_hooks, + mock_client_cls, + mock_handler_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """claude_options values should not be silently dropped when explicit params are None.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + run_claude_agent_activity, + ) + + mock_task_id.get.return_value = "task-1" + mock_trace_id.get.return_value = "trace-1" + mock_parent_span_id.get.return_value = "span-1" + mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": []} + + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_handler = AsyncMock() + mock_handler.get_results = MagicMock(return_value={ + "messages": [], "session_id": "s", "usage": {}, "cost_usd": 0.0, + }) + mock_handler_cls.return_value = mock_handler + + # system_prompt explicit param is None (default), but claude_options has a value + await run_claude_agent_activity( + prompt="Hello", + workspace_path="/workspace", + allowed_tools=["Read"], + claude_options={"system_prompt": "Be helpful"}, + ) + + call_args = mock_client_cls.call_args + options = call_args.kwargs.get("options") or call_args[1].get("options") + assert options.system_prompt == "Be helpful" + + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeMessageHandler", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", + ) + async def test_merges_user_hooks_with_streaming_hooks( + self, + mock_create_hooks, + mock_client_cls, + mock_handler_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """User-provided hooks in claude_options should be merged with streaming hooks.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + run_claude_agent_activity, + ) + + mock_task_id.get.return_value = "task-1" + mock_trace_id.get.return_value = "trace-1" + mock_parent_span_id.get.return_value = "span-1" + + # Streaming hooks + streaming_pre = HookMatcher(matcher=None, hooks=[AsyncMock()]) + mock_create_hooks.return_value = {"PreToolUse": [streaming_pre]} + + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_handler = AsyncMock() + mock_handler.get_results = MagicMock(return_value={ + "messages": [], + "session_id": "s", + "usage": {}, + "cost_usd": 0.0, + }) + mock_handler_cls.return_value = mock_handler + + # User-provided hook via claude_options + user_pre = HookMatcher(matcher="Bash", hooks=[AsyncMock()]) + + await run_claude_agent_activity( + prompt="Hello", + workspace_path="/workspace", + allowed_tools=["Read"], + claude_options={"hooks": {"PreToolUse": [user_pre]}}, + ) + + call_args = mock_client_cls.call_args + options = call_args.kwargs.get("options") or call_args[1].get("options") + # Should have both streaming and user hooks merged + assert len(options.hooks["PreToolUse"]) == 2 + + +class AsyncIteratorMock: + """Helper to mock an async iterator (for client.receive_response()).""" + + def __init__(self, items): + self._items = iter(items) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration from None