From d7434c1401e4e1f7304bf4df6f309e3e314b7d72 Mon Sep 17 00:00:00 2001 From: Shubhi Vashisth Date: Mon, 28 Sep 2026 19:33:11 +0530 Subject: [PATCH] feat(extensibility): add agentExtVersion to extensibility and telemetry --- pyproject.toml | 2 +- src/sap_cloud_sdk/core/telemetry/__init__.py | 4 ++ .../core/telemetry/extensions.py | 40 +++++++++-- src/sap_cloud_sdk/extensibility/_models.py | 4 ++ .../extensibility/_ums_transport.py | 63 ++++++++++++++-- tests/core/unit/telemetry/test_extensions.py | 68 ++++++++++++++++++ tests/extensibility/unit/_ums_test_helpers.py | 18 +++++ tests/extensibility/unit/test_ums_caching.py | 72 +++++++++++++------ .../extensibility/unit/test_ums_pagination.py | 36 +++++++--- .../extensibility/unit/test_ums_transport.py | 6 +- uv.lock | 2 +- 11 files changed, 268 insertions(+), 47 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53e8ea16..aa406a5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.56.0" +version = "0.56.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/core/telemetry/__init__.py b/src/sap_cloud_sdk/core/telemetry/__init__.py index f141334f..e56f0ddf 100644 --- a/src/sap_cloud_sdk/core/telemetry/__init__.py +++ b/src/sap_cloud_sdk/core/telemetry/__init__.py @@ -38,6 +38,7 @@ ATTR_EXTENSION_URL, ATTR_SOLUTION_ID, ATTR_JOULE_STUDIO_GSID, + ATTR_AGENT_EXT_VERSION, ATTR_SUMMARY_TOTAL_OPERATION_COUNT, ATTR_SUMMARY_TOTAL_DURATION_MS, ATTR_SUMMARY_TOOL_CALL_COUNT, @@ -46,6 +47,7 @@ ATTR_SUMMARY_JOULE_STUDIO_GSID, ATTR_SUMMARY_IS_EXTENSION, ATTR_SUMMARY_SOLUTION_ID, + ATTR_SUMMARY_AGENT_EXT_VERSION, resolve_source_info, build_extension_span_attributes, reset_tool_call_metrics, @@ -94,6 +96,7 @@ "ATTR_EXTENSION_URL", "ATTR_SOLUTION_ID", "ATTR_JOULE_STUDIO_GSID", + "ATTR_AGENT_EXT_VERSION", "ATTR_SUMMARY_TOTAL_OPERATION_COUNT", "ATTR_SUMMARY_TOTAL_DURATION_MS", "ATTR_SUMMARY_TOOL_CALL_COUNT", @@ -102,6 +105,7 @@ "ATTR_SUMMARY_JOULE_STUDIO_GSID", "ATTR_SUMMARY_IS_EXTENSION", "ATTR_SUMMARY_SOLUTION_ID", + "ATTR_SUMMARY_AGENT_EXT_VERSION", "resolve_source_info", "build_extension_span_attributes", "reset_tool_call_metrics", diff --git a/src/sap_cloud_sdk/core/telemetry/extensions.py b/src/sap_cloud_sdk/core/telemetry/extensions.py index 96b2a81f..5c8e325a 100644 --- a/src/sap_cloud_sdk/core/telemetry/extensions.py +++ b/src/sap_cloud_sdk/core/telemetry/extensions.py @@ -47,6 +47,7 @@ ATTR_EXTENSION_URL = "sap.extension.extensionUrl" ATTR_SOLUTION_ID = "sap.extension.solution_id" ATTR_JOULE_STUDIO_GSID = "sap.extension.joule_studio_gsid" +ATTR_AGENT_EXT_VERSION = "sap.extension.agentExtVersion" class ExtensionType(str, Enum): @@ -74,6 +75,7 @@ def extension_context( extension_url: str = "", solution_id: str = "", joule_studio_gsid: str = "", + agent_ext_version: str = "", ) -> Generator[None, None, None]: """Set extension context in OTel baggage for propagation. @@ -93,6 +95,7 @@ def extension_context( - ``sap.extension.extensionUrl``: The extension URL (when provided) - ``sap.extension.solution_id``: The solution ID (when provided) - ``sap.extension.joule_studio_gsid``: The global solution ID of Joule Studio (when provided) + - ``sap.extension.agentExtVersion``: The agent extension version counter (when provided) Args: capability_id: The capability ID for the extension @@ -106,6 +109,9 @@ def extension_context( extension_url: The build extension URL (empty string if not available). solution_id: The solution ID (empty string if not available). joule_studio_gsid: The global solution ID of Joule Studio (empty string if not available). + agent_ext_version: The agent extension version counter from UMS + ``AgentExtensionMetadata`` (e.g. ``"5"``). Empty string if not + available; the baggage key is omitted in that case. Yields: None. The context is active for the duration of the with block. @@ -126,6 +132,7 @@ def extension_context( item_name="create_ticket", solution_id="my-solution-42", joule_studio_gsid="gsid-value", + agent_ext_version="5", ): result = await mcp_client.call_tool("create_ticket", args) ``` @@ -150,6 +157,10 @@ def extension_context( ctx = baggage.set_baggage( ATTR_JOULE_STUDIO_GSID, joule_studio_gsid, context=ctx ) + if agent_ext_version: + ctx = baggage.set_baggage( + ATTR_AGENT_EXT_VERSION, agent_ext_version, context=ctx + ) token = attach(ctx) try: @@ -178,6 +189,7 @@ def get_extension_context() -> dict[str, Any] | None: - ``extension_url``: The extension URL (empty string if not set) - ``solution_id``: The solution ID (empty string if not set) - ``joule_studio_gsid``: The global solution ID of Joule Studio (empty string if not set) + - ``agent_ext_version``: The agent extension version counter (empty string if not set) Returns ``None`` if not in an extension context. @@ -205,6 +217,7 @@ def get_extension_context() -> dict[str, Any] | None: "extension_url": baggage.get_baggage(ATTR_EXTENSION_URL) or "", "solution_id": baggage.get_baggage(ATTR_SOLUTION_ID) or "", "joule_studio_gsid": baggage.get_baggage(ATTR_JOULE_STUDIO_GSID) or "", + "agent_ext_version": baggage.get_baggage(ATTR_AGENT_EXT_VERSION) or "", } @@ -220,6 +233,7 @@ def get_extension_context() -> dict[str, Any] | None: ATTR_SUMMARY_JOULE_STUDIO_GSID = "sap.extension.joule_studio_gsid" ATTR_SUMMARY_IS_EXTENSION = "sap.extension.isExtension" ATTR_SUMMARY_SOLUTION_ID = "sap.extension.solutionId" +ATTR_SUMMARY_AGENT_EXT_VERSION = "sap.extension.agentExtVersion" # --------------------------------------------------------------------------- # Private state @@ -242,6 +256,7 @@ def get_extension_context() -> dict[str, Any] | None: (ATTR_EXTENSION_URL, "ext_extension_url"), (ATTR_SOLUTION_ID, "ext_solution_id"), (ATTR_JOULE_STUDIO_GSID, "ext_joule_studio_gsid"), + (ATTR_AGENT_EXT_VERSION, "ext_agent_ext_version"), ] @@ -318,6 +333,7 @@ def build_extension_span_attributes( extension_url: str = "", solution_id: str = "", joule_studio_gsid: str = "", + agent_ext_version: str = "", ) -> dict[str, Any]: """Build the full set of ``sap.extension.*`` span attributes. @@ -331,6 +347,7 @@ def build_extension_span_attributes( extension_url: Build extension URL (empty string if not available). solution_id: Solution ID (empty string if not available). joule_studio_gsid: Global solution ID (empty string if not available). + agent_ext_version: Agent extension version from UMS (empty string if not available). Returns: Dict with all ``sap.extension.*`` attribute keys. @@ -350,6 +367,8 @@ def build_extension_span_attributes( attrs[ATTR_SOLUTION_ID] = solution_id if joule_studio_gsid: attrs[ATTR_JOULE_STUDIO_GSID] = joule_studio_gsid + if agent_ext_version: + attrs[ATTR_AGENT_EXT_VERSION] = agent_ext_version return attrs @@ -440,13 +459,13 @@ async def call_extension_tool( args: dict[str, Any], capability: str = "default", source_mapping: dict[str, Any] | None = None, + agent_ext_version: str = "", ) -> Any: """Call an MCP tool with telemetry instrumentation. Wraps the tool call with ``extension_context`` (sets OTel baggage for downstream propagation) and creates an explicit tracer span with all - seven ``sap.extension.*`` attributes so the call is visible in - agent-side traces. + ``sap.extension.*`` attributes so the call is visible in agent-side traces. Args: mcp_client: The MCP client session connected to the tool's server. @@ -461,6 +480,8 @@ async def call_extension_tool( objects (from ``ext_impl.source.tools``). Keys must match the *tool_name* values passed to this function. See :class:`~sap_cloud_sdk.extensibility.ExtensionSourceMapping`. + agent_ext_version: Agent extension version from UMS AgentExtensionMetadata + (pass ``ext_impl.agent_ext_version``; empty string if not available). Returns: The tool's response from the MCP server. @@ -487,6 +508,7 @@ async def call_extension_tool( extension_url=resolved_url, solution_id=resolved_solution_id, joule_studio_gsid=resolved_joule_studio_gsid, + agent_ext_version=agent_ext_version, ) t0 = time.monotonic() @@ -502,6 +524,7 @@ async def call_extension_tool( extension_url=resolved_url, solution_id=resolved_solution_id, joule_studio_gsid=resolved_joule_studio_gsid, + agent_ext_version=agent_ext_version, ), _tracer.start_as_current_span( f"extension_tool {tool_name}", @@ -529,13 +552,13 @@ async def call_extension_hook( capability: str = "default", source_mapping: dict[str, Any] | None = None, hook_id: str = "", + agent_ext_version: str = "", ) -> Any: """Call an extension hook with telemetry instrumentation. Wraps the hook call with ``extension_context`` (sets OTel baggage for downstream propagation) and creates an explicit tracer span with all - seven ``sap.extension.*`` attributes so the call is visible in - agent-side traces. + ``sap.extension.*`` attributes so the call is visible in agent-side traces. Args: extensibility_client: The extensibility client. Must have an async @@ -550,6 +573,8 @@ async def call_extension_hook( objects (from ``ext_impl.source.hooks``). hook_id: The unique hook ``id`` (UUID), used as lookup key in *source_mapping*. + agent_ext_version: Agent extension version from UMS AgentExtensionMetadata + (pass ``ext_impl.agent_ext_version``; empty string if not available). Returns: The hook's response. @@ -575,6 +600,7 @@ async def call_extension_hook( extension_url=resolved_url, solution_id=resolved_solution_id, joule_studio_gsid=resolved_joule_studio_gsid, + agent_ext_version=agent_ext_version, ) t0 = time.monotonic() @@ -590,6 +616,7 @@ async def call_extension_hook( extension_url=resolved_url, solution_id=resolved_solution_id, joule_studio_gsid=resolved_joule_studio_gsid, + agent_ext_version=agent_ext_version, ), _tracer.start_as_current_span( f"extension_hook {item_name}", @@ -617,6 +644,7 @@ def emit_extensions_summary_span( total_duration_ms: float, joule_studio_gsid: str = "", solution_id: str = "", + agent_ext_version: str = "", ) -> None: """Emit a sibling summary span with aggregate extension metrics. @@ -642,6 +670,8 @@ def emit_extensions_summary_span( if not available). solution_id: Solution ID of the contributing extension (empty string if not available). + agent_ext_version: Agent extension version from UMS AgentExtensionMetadata + (empty string if not available). """ total = tool_call_count + hook_call_count + (1 if has_instruction else 0) attrs = { @@ -656,6 +686,8 @@ def emit_extensions_summary_span( attrs[ATTR_SUMMARY_JOULE_STUDIO_GSID] = joule_studio_gsid if solution_id: attrs[ATTR_SUMMARY_SOLUTION_ID] = solution_id + if agent_ext_version: + attrs[ATTR_SUMMARY_AGENT_EXT_VERSION] = agent_ext_version span = _tracer.start_span("agent_extensions_summary", attributes=attrs) span.end() diff --git a/src/sap_cloud_sdk/extensibility/_models.py b/src/sap_cloud_sdk/extensibility/_models.py index 7780484a..0eb26750 100644 --- a/src/sap_cloud_sdk/extensibility/_models.py +++ b/src/sap_cloud_sdk/extensibility/_models.py @@ -659,6 +659,9 @@ class ExtensionCapabilityImplementation: joule_studio_gsid: Global solution ID of Joule Studio. Set when a single Joule Studio extension contributes to this capability; empty string otherwise. + agent_ext_version: The agent's current extension version counter + (e.g., ``"5"``). ``None`` when no extensions are deployed or + ``AgentExtensionMetadata`` is not yet populated in UMS. """ capability_id: str @@ -668,6 +671,7 @@ class ExtensionCapabilityImplementation: hooks: List[Hook] = field(default_factory=list) source: Optional[ExtensionSourceMapping] = None joule_studio_gsid: str = "" + agent_ext_version: Optional[str] = None @classmethod def from_dict(cls, obj: Dict[str, Any]) -> ExtensionCapabilityImplementation: diff --git a/src/sap_cloud_sdk/extensibility/_ums_transport.py b/src/sap_cloud_sdk/extensibility/_ums_transport.py index 2573d4e0..62100c79 100644 --- a/src/sap_cloud_sdk/extensibility/_ums_transport.py +++ b/src/sap_cloud_sdk/extensibility/_ums_transport.py @@ -111,6 +111,20 @@ % _GRAPHQL_QUERY_FRAGMENT ) +_GRAPHQL_AGENT_EXT_VERSION_QUERY = """\ +query GetAgentExtMetadata($metaFilters: EXTHUB__AgentExtensionMetadataFilterInput) { + EXTHUB__AgentExtensionMetadataInstances( + filters: $metaFilters + first: 1 + ) { + edges { + node { + agentExtensionVersion + } + } + } +}""" + _GRAPHQL_HEADERS: dict[str, str] = { "Content-Type": "application/json", "Accept": "application/json", @@ -365,12 +379,14 @@ def _build_source_mapping( def _transform_ums_response( data: Dict[str, Any], capability_id: str, + agent_ext_version: Optional[str] = None, ) -> ExtensionCapabilityImplementation: """Transform a UMS GraphQL response into an :class:`ExtensionCapabilityImplementation`. Args: data: The ``data`` portion of the GraphQL JSON response. capability_id: The requested capability ID to filter by. + agent_ext_version: Agent extension version from ``AgentExtensionMetadata``. Returns: A populated ``ExtensionCapabilityImplementation``. @@ -434,6 +450,7 @@ def _transform_ums_response( hooks=hooks, source=source, joule_studio_gsid=joule_studio_gsid, + agent_ext_version=agent_ext_version, ) @@ -480,7 +497,7 @@ def __init__(self, agent_ord_id: str, config: ExtensibilityConfig) -> None: ) self._cache: collections.OrderedDict[ tuple[str, str], - tuple[float, List[Dict[str, Any]]], + tuple[float, List[Dict[str, Any]], Optional[str]], ] = collections.OrderedDict() self._cache_lock = threading.Lock() @@ -534,7 +551,7 @@ def get_extension_capability_implementation( with self._cache_lock: cached = self._cache.get(cache_key) if cached is not None: - ts, cached_edges = cached + ts, cached_edges, cached_agent_ext_version = cached if (time.monotonic() - ts) < _CACHE_TTL_SECONDS: logger.debug( "UMS cache hit for tenant=%s capability_id=%s", @@ -548,7 +565,9 @@ def get_extension_capability_implementation( "edges": all_edges }, } - return _transform_ums_response(combined_data, capability_id) + return _transform_ums_response( + combined_data, capability_id, cached_agent_ext_version + ) logger.debug( "UMS cache expired for tenant=%s capability_id=%s", tenant, @@ -638,6 +657,7 @@ def get_extension_capability_implementation( # 5. Send paginated requests with mTLS -------------------------- all_edges = [] + agent_ext_version: Optional[str] = None cursor: Optional[str] = None try: with tempfile.NamedTemporaryFile(suffix=".pem") as cert_file: @@ -645,6 +665,7 @@ def get_extension_capability_implementation( cert_file.flush() with httpx.Client(cert=cert_file.name) as client: + # 5a. Paginated ExtCapImplementation query --------------- for _ in range(_MAX_PAGES): if cursor is not None: query = _GRAPHQL_QUERY_WITH_CURSOR @@ -706,6 +727,34 @@ def get_extension_capability_implementation( break cursor = page_info.get("cursor") + # 5b. Separate query for agentExtensionVersion ---------- + try: + meta_body = { + "query": _GRAPHQL_AGENT_EXT_VERSION_QUERY, + "variables": {"metaFilters": {"agent": agent_filter}}, + } + meta_response = client.post( + url, + json=meta_body, + headers=request_headers, + ) + meta_response.raise_for_status() + meta_body_parsed = meta_response.json() + meta_data = meta_body_parsed.get("data") or {} + meta_edges = meta_data.get( + "EXTHUB__AgentExtensionMetadataInstances", {} + ).get("edges", []) + if meta_edges: + meta_node = (meta_edges[0] or {}).get("node") or {} + agent_ext_version = ( + meta_node.get("agentExtensionVersion") or None + ) + except Exception: + logger.warning( + "Failed to fetch AgentExtensionMetadata; agent_ext_version will be None", + exc_info=True, + ) + except TransportError: raise except Exception as exc: @@ -718,7 +767,7 @@ def get_extension_capability_implementation( # Evict expired entries first. expired_keys = [ k - for k, (ts, _) in self._cache.items() + for k, (ts, _, _v) in self._cache.items() if (now - ts) >= _CACHE_TTL_SECONDS ] for k in expired_keys: @@ -728,12 +777,14 @@ def get_extension_capability_implementation( while len(self._cache) >= _CACHE_MAX_SIZE: self._cache.popitem(last=False) - self._cache[cache_key] = (now, all_edges) + self._cache[cache_key] = (now, all_edges, agent_ext_version) # 8. Transform ----------------------------------------------------------- combined_data: Dict[str, Any] = { "EXTHUB__ExtCapImplementationInstances": {"edges": all_edges}, } - result = _transform_ums_response(combined_data, capability_id) + result = _transform_ums_response( + combined_data, capability_id, agent_ext_version + ) return result diff --git a/tests/core/unit/telemetry/test_extensions.py b/tests/core/unit/telemetry/test_extensions.py index f32fcfea..13dfb9f4 100644 --- a/tests/core/unit/telemetry/test_extensions.py +++ b/tests/core/unit/telemetry/test_extensions.py @@ -20,12 +20,14 @@ ATTR_EXTENSION_URL, ATTR_SOLUTION_ID, ATTR_JOULE_STUDIO_GSID, + ATTR_AGENT_EXT_VERSION, ATTR_SUMMARY_TOTAL_OPERATION_COUNT, ATTR_SUMMARY_TOTAL_DURATION_MS, ATTR_SUMMARY_TOOL_CALL_COUNT, ATTR_SUMMARY_HOOK_CALL_COUNT, ATTR_SUMMARY_HAS_INSTRUCTION, ATTR_SUMMARY_JOULE_STUDIO_GSID, + ATTR_SUMMARY_AGENT_EXT_VERSION, resolve_source_info, build_extension_span_attributes, reset_tool_call_metrics, @@ -131,6 +133,7 @@ def mock_set_baggage(key, value, context=None): extension_url="https://ext.example.com", solution_id="sol-789", joule_studio_gsid="gsid-test-123", + agent_ext_version="5", ): pass @@ -144,6 +147,7 @@ def mock_set_baggage(key, value, context=None): assert captured_baggage[ATTR_EXTENSION_URL] == "https://ext.example.com" assert captured_baggage[ATTR_SOLUTION_ID] == "sol-789" assert captured_baggage[ATTR_JOULE_STUDIO_GSID] == "gsid-test-123" + assert captured_baggage[ATTR_AGENT_EXT_VERSION] == "5" def test_extension_context_defaults_for_new_params(self): """Test extension_context uses defaults when new params are omitted.""" @@ -182,6 +186,7 @@ def mock_set_baggage(key, value, context=None): assert ATTR_EXTENSION_URL not in captured_baggage assert ATTR_SOLUTION_ID not in captured_baggage assert ATTR_JOULE_STUDIO_GSID not in captured_baggage + assert ATTR_AGENT_EXT_VERSION not in captured_baggage def test_extension_context_hook_type(self): """Test extension_context with hook extension type.""" @@ -573,6 +578,7 @@ def test_extension_context_and_get_work_together(self): extension_url="https://ext.example.com", solution_id="sol-789", joule_studio_gsid="gsid-789", + agent_ext_version="3", ): result_during = get_extension_context() assert result_during is not None @@ -586,6 +592,7 @@ def test_extension_context_and_get_work_together(self): assert result_during["extension_url"] == "https://ext.example.com" assert result_during["solution_id"] == "sol-789" assert result_during["joule_studio_gsid"] == "gsid-789" + assert result_during["agent_ext_version"] == "3" result_after = get_extension_context() assert result_after is None @@ -650,6 +657,7 @@ def test_extension_context_defaults_integration(self): assert result["extension_url"] == "" assert result["solution_id"] == "" assert result["joule_studio_gsid"] == "" + assert result["agent_ext_version"] == "" # --------------------------------------------------------------------------- @@ -1106,6 +1114,37 @@ def test_joule_studio_gsid_omitted_when_empty(self): attrs = mock_tracer.start_span.call_args[1]["attributes"] assert ATTR_SUMMARY_JOULE_STUDIO_GSID not in attrs + def test_agent_ext_version_included_when_provided(self): + with patch("sap_cloud_sdk.core.telemetry.extensions._tracer") as mock_tracer: + mock_span = MagicMock() + mock_tracer.start_span.return_value = mock_span + + emit_extensions_summary_span( + tool_call_count=1, + hook_call_count=0, + has_instruction=False, + total_duration_ms=100.0, + agent_ext_version="5", + ) + + attrs = mock_tracer.start_span.call_args[1]["attributes"] + assert attrs[ATTR_SUMMARY_AGENT_EXT_VERSION] == "5" + + def test_agent_ext_version_omitted_when_empty(self): + with patch("sap_cloud_sdk.core.telemetry.extensions._tracer") as mock_tracer: + mock_span = MagicMock() + mock_tracer.start_span.return_value = mock_span + + emit_extensions_summary_span( + tool_call_count=1, + hook_call_count=0, + has_instruction=False, + total_duration_ms=100.0, + ) + + attrs = mock_tracer.start_span.call_args[1]["attributes"] + assert ATTR_SUMMARY_AGENT_EXT_VERSION not in attrs + # --------------------------------------------------------------------------- # ExtensionContextLogFilter @@ -1129,6 +1168,7 @@ def test_adds_attributes_in_extension_context(self): extension_url="https://ext.example.com", solution_id="sol-42", joule_studio_gsid="gsid-42", + agent_ext_version="7", ): result = filt.filter(record) @@ -1143,6 +1183,7 @@ def test_adds_attributes_in_extension_context(self): assert getattr(record, "ext_extension_url") == "https://ext.example.com" assert getattr(record, "ext_solution_id") == "sol-42" assert getattr(record, "ext_joule_studio_gsid") == "gsid-42" + assert getattr(record, "ext_agent_ext_version") == "7" def test_no_attributes_outside_context(self): filt = ExtensionContextLogFilter() @@ -1179,3 +1220,30 @@ def test_empty_values_set_as_empty_string(self): assert getattr(record, "ext_extension_url") == "" assert getattr(record, "ext_solution_id") == "" assert getattr(record, "ext_joule_studio_gsid") == "" + assert getattr(record, "ext_agent_ext_version") == "" + + def test_agent_ext_version_set_in_log_record(self): + """When agent_ext_version is provided, ext_agent_ext_version is stamped on the record.""" + filt = ExtensionContextLogFilter() + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + + with extension_context( + "cap", + "ext", + ExtensionType.TOOL, + agent_ext_version="42", + ): + filt.filter(record) + + assert getattr(record, "ext_agent_ext_version") == "42" + + def test_agent_ext_version_empty_when_not_set(self): + """When agent_ext_version is omitted, ext_agent_ext_version is empty string on the record.""" + filt = ExtensionContextLogFilter() + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + + with extension_context("cap", "ext", ExtensionType.TOOL): + filt.filter(record) + + assert getattr(record, "ext_agent_ext_version") == "" + assert getattr(record, "ext_agent_ext_version") == "" diff --git a/tests/extensibility/unit/_ums_test_helpers.py b/tests/extensibility/unit/_ums_test_helpers.py index 2a91e4a0..7abf42e1 100644 --- a/tests/extensibility/unit/_ums_test_helpers.py +++ b/tests/extensibility/unit/_ums_test_helpers.py @@ -233,6 +233,24 @@ } } +UMS_META_EMPTY = { + "data": { + "EXTHUB__AgentExtensionMetadataInstances": { + "edges": [] + } + } +} + +UMS_META_WITH_VERSION = { + "data": { + "EXTHUB__AgentExtensionMetadataInstances": { + "edges": [ + {"node": {"agentExtensionVersion": "5"}} + ] + } + } +} + UMS_RESPONSE_DIFFERENT_CAPABILITY = { "data": { "EXTHUB__ExtCapImplementationInstances": { diff --git a/tests/extensibility/unit/test_ums_caching.py b/tests/extensibility/unit/test_ums_caching.py index 83f1cf95..bb33105f 100644 --- a/tests/extensibility/unit/test_ums_caching.py +++ b/tests/extensibility/unit/test_ums_caching.py @@ -18,6 +18,7 @@ UMS_RESPONSE_SINGLE, UMS_RESPONSE_MULTIPLE, UMS_RESPONSE_DIFFERENT_CAPABILITY, + UMS_META_EMPTY, _make_config, _make_dest, _make_httpx_response, @@ -55,7 +56,14 @@ def test_cache_hit_returns_cached_result(self): """Second call with same capability_id returns cached result without HTTP.""" transport, _ = self._make_transport() response = _make_httpx_response(UMS_RESPONSE_SINGLE) - patcher, mock_client = self._patch_httpx(response) + meta_response = _make_httpx_response(UMS_META_EMPTY) + + patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") + mock_client_cls = patcher.start() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.post.side_effect = [response, meta_response] try: result1 = transport.get_extension_capability_implementation() @@ -63,8 +71,8 @@ def test_cache_hit_returns_cached_result(self): finally: patcher.stop() - # Only one HTTP call should have been made - assert mock_client.post.call_count == 1 + # Two HTTP calls for the first transport call (ext cap + metadata), zero for the cache hit + assert mock_client.post.call_count == 2 # Both results should be equal (not identical, since we transform on each read) assert result1 == result2 assert result1.extension_names == ["ServiceNow Extension"] @@ -85,8 +93,8 @@ def test_cache_miss_on_different_capability_id(self): finally: patcher.stop() - # Two HTTP calls -- one per distinct capability_id - assert mock_client.post.call_count == 2 + # Four HTTP calls -- two per distinct capability_id (ext cap + metadata each) + assert mock_client.post.call_count == 4 assert result1.capability_id == "default" assert result2.capability_id == "onboarding" @@ -101,18 +109,18 @@ def test_cache_expires_after_ttl(self, mock_time): mock_time.monotonic.return_value = 0.0 try: result1 = transport.get_extension_capability_implementation() - assert mock_client.post.call_count == 1 + assert mock_client.post.call_count == 2 # Second call at t=599 (within TTL) -- should be cached mock_time.monotonic.return_value = 599.0 result2 = transport.get_extension_capability_implementation() - assert mock_client.post.call_count == 1 + assert mock_client.post.call_count == 2 assert result2 == result1 # Third call at t=601 (past TTL) -- should fetch fresh mock_time.monotonic.return_value = 601.0 transport.get_extension_capability_implementation() - assert mock_client.post.call_count == 2 + assert mock_client.post.call_count == 4 finally: patcher.stop() @@ -125,11 +133,11 @@ def test_skip_cache_bypasses_cache(self): try: # Populate cache transport.get_extension_capability_implementation() - assert mock_client.post.call_count == 1 + assert mock_client.post.call_count == 2 # skip_cache=True should bypass transport.get_extension_capability_implementation(skip_cache=True) - assert mock_client.post.call_count == 2 + assert mock_client.post.call_count == 4 finally: patcher.stop() @@ -139,14 +147,20 @@ def test_skip_cache_updates_cache(self): response1 = _make_httpx_response(UMS_RESPONSE_SINGLE) response2 = _make_httpx_response(UMS_RESPONSE_MULTIPLE) + meta_response = _make_httpx_response(UMS_META_EMPTY) patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") mock_client_cls = patcher.start() mock_client = MagicMock() mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) - # First call returns SINGLE, second (skip_cache) returns MULTIPLE - mock_client.post.side_effect = [response1, response2] + # Each transport call = ext cap POST + metadata POST + mock_client.post.side_effect = [ + response1, + meta_response, + response2, + meta_response, + ] try: # Populate cache with SINGLE @@ -160,8 +174,8 @@ def test_skip_cache_updates_cache(self): # Normal call should now return the MULTIPLE result from cache result3 = transport.get_extension_capability_implementation() assert result3 == result2 - # Only 2 HTTP calls total (the third was a cache hit) - assert mock_client.post.call_count == 2 + # Only 4 HTTP calls total (the third was a cache hit) + assert mock_client.post.call_count == 4 finally: patcher.stop() @@ -170,13 +184,15 @@ def test_cache_not_populated_on_error(self): transport, dest_client = self._make_transport() error_response = _make_httpx_response({"error": "fail"}, status_code=500) success_response = _make_httpx_response(UMS_RESPONSE_SINGLE) + meta_response = _make_httpx_response(UMS_META_EMPTY) patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") mock_client_cls = patcher.start() mock_client = MagicMock() mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) - mock_client.post.side_effect = [error_response, success_response] + # First ext cap call fails (no metadata call); second succeeds + metadata + mock_client.post.side_effect = [error_response, success_response, meta_response] try: # First call fails @@ -186,7 +202,7 @@ def test_cache_not_populated_on_error(self): # Cache should be empty, so second call makes a real HTTP request result = transport.get_extension_capability_implementation() assert result.extension_names == ["ServiceNow Extension"] - assert mock_client.post.call_count == 2 + assert mock_client.post.call_count == 3 finally: patcher.stop() @@ -200,13 +216,19 @@ def test_cache_isolated_by_tenant(self): response1 = _make_httpx_response(UMS_RESPONSE_SINGLE) response2 = _make_httpx_response(UMS_RESPONSE_MULTIPLE) + meta_response = _make_httpx_response(UMS_META_EMPTY) patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") mock_client_cls = patcher.start() mock_client = MagicMock() mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) - mock_client.post.side_effect = [response1, response2] + mock_client.post.side_effect = [ + response1, + meta_response, + response2, + meta_response, + ] try: result_a = transport.get_extension_capability_implementation( @@ -218,8 +240,8 @@ def test_cache_isolated_by_tenant(self): finally: patcher.stop() - # Two HTTP calls -- one per tenant - assert mock_client.post.call_count == 2 + # Four HTTP calls -- two per tenant (ext cap + metadata each) + assert mock_client.post.call_count == 4 assert result_a.extension_names == ["ServiceNow Extension"] assert result_b.extension_names == ["ServiceNow Extension", "Jira Extension"] @@ -239,7 +261,7 @@ def test_cache_hit_same_tenant(self): finally: patcher.stop() - assert mock_client.post.call_count == 1 + assert mock_client.post.call_count == 2 assert result1 == result2 def test_different_tenants_are_separate_cache_keys(self): @@ -248,13 +270,19 @@ def test_different_tenants_are_separate_cache_keys(self): response1 = _make_httpx_response(UMS_RESPONSE_SINGLE) response2 = _make_httpx_response(UMS_RESPONSE_MULTIPLE) + meta_response = _make_httpx_response(UMS_META_EMPTY) patcher = patch("sap_cloud_sdk.extensibility._ums_transport.httpx.Client") mock_client_cls = patcher.start() mock_client = MagicMock() mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) - mock_client.post.side_effect = [response1, response2] + mock_client.post.side_effect = [ + response1, + meta_response, + response2, + meta_response, + ] try: result_a = transport.get_extension_capability_implementation( @@ -266,7 +294,7 @@ def test_different_tenants_are_separate_cache_keys(self): finally: patcher.stop() - assert mock_client.post.call_count == 2 + assert mock_client.post.call_count == 4 assert result_a is not result_b def test_cache_max_size_constant(self): diff --git a/tests/extensibility/unit/test_ums_pagination.py b/tests/extensibility/unit/test_ums_pagination.py index 35d06277..57d588f7 100644 --- a/tests/extensibility/unit/test_ums_pagination.py +++ b/tests/extensibility/unit/test_ums_pagination.py @@ -16,6 +16,7 @@ AGENT_ORD_ID, UMS_RESPONSE_SINGLE, UMS_RESPONSE_EMPTY, + UMS_META_EMPTY, _make_config, _make_dest, _make_httpx_response, @@ -40,7 +41,7 @@ def _make_transport(self, mock_dest_client, dest=None): return transport, mock_dest_client.return_value def test_single_page_no_next(self): - """A response with hasNextPage=False results in one HTTP call.""" + """A response with hasNextPage=False results in two HTTP calls (ext cap + metadata).""" transport, _ = self._make_transport() response = _make_httpx_response(UMS_RESPONSE_SINGLE) @@ -50,11 +51,14 @@ def test_single_page_no_next(self): mock_client = MagicMock() mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) - mock_client.post.return_value = response + mock_client.post.side_effect = [ + response, + _make_httpx_response(UMS_META_EMPTY), + ] result = transport.get_extension_capability_implementation() - assert mock_client.post.call_count == 1 + assert mock_client.post.call_count == 2 assert result.extension_names == ["ServiceNow Extension"] assert len(result.mcp_servers) == 1 @@ -144,12 +148,16 @@ def test_multiple_pages_accumulates_edges(self): mock_client = MagicMock() mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) - mock_client.post.side_effect = [page1_response, page2_response] + mock_client.post.side_effect = [ + page1_response, + page2_response, + _make_httpx_response(UMS_META_EMPTY), + ] result = transport.get_extension_capability_implementation() - # Two HTTP calls - assert mock_client.post.call_count == 2 + # Three HTTP calls: two pages + one metadata query + assert mock_client.post.call_count == 3 # Both extensions are merged assert result.extension_names == ["Extension A", "Extension B"] @@ -214,7 +222,11 @@ def test_cursor_sent_on_subsequent_pages(self): mock_client = MagicMock() mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) - mock_client.post.side_effect = [page1_response, page2_response] + mock_client.post.side_effect = [ + page1_response, + page2_response, + _make_httpx_response(UMS_META_EMPTY), + ] original_post = mock_client.post @@ -228,7 +240,7 @@ def capturing_post(*args, **kwargs): transport.get_extension_capability_implementation() - assert len(captured_vars) == 2 + assert len(captured_vars) == 3 # First call: no 'after' variable (uses _GRAPHQL_QUERY without $after) assert "after" not in captured_vars[0] @@ -236,6 +248,10 @@ def capturing_post(*args, **kwargs): # Second call: after="abc123" (uses _GRAPHQL_QUERY_WITH_CURSOR) assert captured_vars[1]["after"] == "abc123" + # Third call: metadata query — uses metaFilters, no 'filters' key + assert "metaFilters" in captured_vars[2] + assert "after" not in captured_vars[2] + def test_empty_first_page_no_further_requests(self): """Empty edges with hasNextPage=False stops after one request.""" transport, _ = self._make_transport() @@ -251,7 +267,7 @@ def test_empty_first_page_no_further_requests(self): result = transport.get_extension_capability_implementation() - assert mock_client.post.call_count == 1 + assert mock_client.post.call_count == 2 assert result.extension_names == [] assert result.mcp_servers == [] @@ -336,7 +352,7 @@ def test_missing_page_info_stops_pagination(self): result = transport.get_extension_capability_implementation() - assert mock_client.post.call_count == 1 + assert mock_client.post.call_count == 2 assert result.extension_names == ["Ext"] def test_max_pages_constant(self): diff --git a/tests/extensibility/unit/test_ums_transport.py b/tests/extensibility/unit/test_ums_transport.py index 7e4b449e..692a4283 100644 --- a/tests/extensibility/unit/test_ums_transport.py +++ b/tests/extensibility/unit/test_ums_transport.py @@ -191,8 +191,8 @@ def test_sends_correct_graphql_query(self): transport.get_extension_capability_implementation() - # Verify the URL - call_args = mock_client.post.call_args + # Verify the URL and GraphQL body of the first (capability) call + call_args = mock_client.post.call_args_list[0] assert call_args[0][0] == "https://ums.example.com/graphql" # Verify the GraphQL body @@ -476,7 +476,7 @@ def test_tenant_included_in_agent_filter(self): transport.get_extension_capability_implementation(tenant="my-subscriber") - json_body = mock_client.post.call_args[1]["json"] + json_body = mock_client.post.call_args_list[0][1]["json"] agent_filter = json_body["variables"]["filters"]["agent"] assert agent_filter["ordIdEquals"] == AGENT_ORD_ID assert agent_filter["uclSystemInstance"] == { diff --git a/uv.lock b/uv.lock index 19e2ef24..4e740820 100644 --- a/uv.lock +++ b/uv.lock @@ -4349,7 +4349,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.56.0" +version = "0.56.1" source = { editable = "." } dependencies = [ { name = "cryptography" },