From ea7654e89d6b28ae111d4073a461b2a2e0a594c4 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 15:33:40 +0530 Subject: [PATCH 1/2] [FIX] Keep the Prompt Studio back-reference in tool registry listing; drop dead adapter-choices route 1. `tool` listing dropped the link back to the Prompt Studio project After `export-tool` there was no way to correlate a registry entry with the project that produced it. `fetch_json_for_registry` projected only four keys - `name`, `description`, `icon`, `function_name` - where `function_name` is the `prompt_registry_id`, a fresh UUID, not the Prompt Studio `tool_id`. Callers were left matching on `name`, which is ambiguous whenever two projects share one. The data was already there and simply discarded: `PromptStudioRegistry` has a `custom_tool` OneToOneField pointing straight back at the project, and the serializer is `fields = "__all__"`, so it is already present in the serialized data. The listing now carries it. The frontend shows the cost of the omission today: tool.function_name === toolDetails.tool_id || tool.name === toolDetails.tool_name The first clause can never match (a registry UUID is compared against a Prompt Studio tool_id), so correlation silently falls through to the ambiguous name comparison. With `custom_tool` exposed, callers can correlate exactly. `custom_tool` is `null=True`, so legacy rows report `None` rather than failing. Additive only - no existing key changes. 2. `prompt-studio/adapter-choices/` routed to a method that does not exist The URL mapped `get` to `get_adapter_choices`, which is not defined on `PromptStudioCoreView` or anywhere else in the backend. The route resolved, the handler was missing, and every request returned 500 `server_error`. Removed the route and its `as_view` registration so the path 404s honestly instead of 500ing. Verified there are no other references: the only two in the repo were the route registration and the URL entry, and no frontend code calls it. `adapter list --adapter-type LLM` already covers this server-side. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/prompt_studio/prompt_studio_core_v2/urls.py | 8 -------- .../prompt_studio/prompt_studio_registry_v2/constants.py | 1 + .../prompt_studio_registry_helper.py | 6 ++++++ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/prompt_studio/prompt_studio_core_v2/urls.py b/backend/prompt_studio/prompt_studio_core_v2/urls.py index 4a453fa309..f0c6b51e14 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/urls.py +++ b/backend/prompt_studio/prompt_studio_core_v2/urls.py @@ -30,9 +30,6 @@ prompt_studio_prompt_index = PromptStudioCoreView.as_view({"post": "index_document"}) prompt_studio_prompt_response = PromptStudioCoreView.as_view({"post": "fetch_response"}) -prompt_studio_adapter_choices = PromptStudioCoreView.as_view( - {"get": "get_adapter_choices"} -) prompt_studio_bulk_fetch_response = PromptStudioCoreView.as_view( {"post": "bulk_fetch_response"} ) @@ -123,11 +120,6 @@ prompt_studio_bulk_fetch_response, name="prompt-studio-bulk-fetch-response", ), - path( - "prompt-studio/adapter-choices/", - prompt_studio_adapter_choices, - name="prompt-studio-adapter-choices", - ), path( "prompt-studio/single-pass-extraction/", prompt_studio_single_pass_extraction, diff --git a/backend/prompt_studio/prompt_studio_registry_v2/constants.py b/backend/prompt_studio/prompt_studio_registry_v2/constants.py index 35d6654851..e27715a326 100644 --- a/backend/prompt_studio/prompt_studio_registry_v2/constants.py +++ b/backend/prompt_studio/prompt_studio_registry_v2/constants.py @@ -88,6 +88,7 @@ class JsonSchemaKey: EMBEDDING_SUFFIX = "embedding_suffix" FUNCTION_NAME = "function_name" PROMPT_REGISTRY_ID = "prompt_registry_id" + CUSTOM_TOOL = "custom_tool" NOTES = "NOTES" TOOL_SETTINGS = "tool_settings" ENABLE_CHALLENGE = "enable_challenge" diff --git a/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py b/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py index 4fee8c10bc..c6eaea07bb 100644 --- a/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py +++ b/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py @@ -443,6 +443,12 @@ def fetch_json_for_registry(user: User) -> list[dict[str, Any]]: tool_metadata[JsonSchemaKey.FUNCTION_NAME] = prompts.get( JsonSchemaKey.PROMPT_REGISTRY_ID ) + # Back-reference to the Prompt Studio project that produced this + # entry. Without it callers can only correlate on `name`, which is + # ambiguous when two projects share one. + tool_metadata[JsonSchemaKey.CUSTOM_TOOL] = prompts.get( + JsonSchemaKey.CUSTOM_TOOL + ) tool_list.append(tool_metadata) tool_metadata = {} return tool_list From 6f0d6dd8c51f4222ff32d1fddb848b5a9b14790a Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 21:12:27 +0530 Subject: [PATCH 2/2] [TEST] Pin the Prompt Studio back-reference in the registry listing Regression tests for the `custom_tool` projection added in this PR. Exercises the real `fetch_json_for_registry` body, stubbing only the ORM/serializer boundary, since the function is a pure projection over already-serialized rows. Django is not importable in a plain checkout, so the body is extracted from source - mirroring `prompt_studio_core_v2/tests/test_build_index_payload.py`. Coverage: - the listing carries `custom_tool` (the Prompt Studio tool_id) - `function_name` remains the registry UUID and is distinct from `custom_tool`, pinning the distinction that made the back-reference necessary and guarding against "fixing" the frontend's dead comparison by redefining `function_name` instead - existing keys are preserved, so the change stays additive - an unlinked legacy row (`custom_tool` is nullable) reports None rather than raising, and is not dropped - rows do not bleed into each other: the loop reuses one dict and resets it per row, and a missing reset would smear one project's back-reference onto the next - callers would then correlate confidently and wrongly - two projects sharing a name stay distinguishable, which is the scenario the old four-key projection could not represent Verified by mutation: removing the `custom_tool` projection fails five of the six tests, with the sixth (existing-keys) correctly unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/test_fetch_json_for_registry.py | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 backend/prompt_studio/prompt_studio_registry_v2/tests/test_fetch_json_for_registry.py diff --git a/backend/prompt_studio/prompt_studio_registry_v2/tests/test_fetch_json_for_registry.py b/backend/prompt_studio/prompt_studio_registry_v2/tests/test_fetch_json_for_registry.py new file mode 100644 index 0000000000..186b16661c --- /dev/null +++ b/backend/prompt_studio/prompt_studio_registry_v2/tests/test_fetch_json_for_registry.py @@ -0,0 +1,226 @@ +"""Regression tests for ``PromptStudioRegistryHelper.fetch_json_for_registry``. + +Pins the fix that keeps the back-reference to the Prompt Studio project in the +tool registry listing. + +Before the fix the projection carried only four keys -- ``name``, +``description``, ``icon``, ``function_name`` -- where ``function_name`` is the +``prompt_registry_id``, a fresh UUID unrelated to the Prompt Studio +``tool_id``. Callers were left correlating on ``name``, which is ambiguous +whenever two projects share one. The data was already present (``custom_tool`` +is a ``OneToOneField`` and the serializer is ``fields = "__all__"``); the +projection simply dropped it. + +The frontend shows the cost directly, in +``CreateApiDeploymentFromPromptStudio.jsx``:: + + tool.function_name === toolDetails.tool_id || tool.name === toolDetails.tool_name + +The first clause can never be true, so correlation silently falls through to +the ambiguous name comparison. + +``fetch_json_for_registry`` is a pure projection over already-serialized data, +so these tests stub the ORM/serializer boundary and exercise the real loop +body. Django is not importable in a plain checkout (no ``pytest-django``), and +the helper module is Django-coupled, so the function body is extracted from +source -- mirroring ``prompt_studio_core_v2/tests/test_build_index_payload.py``. +If the function is renamed or restructured these tests fail rather than +silently skip. +""" + +from __future__ import annotations + +import importlib.util +import textwrap +from pathlib import Path +from typing import Any + +import pytest + +BACKEND_DIR = Path(__file__).resolve().parents[3] +REGISTRY_DIR = BACKEND_DIR / "prompt_studio" / "prompt_studio_registry_v2" +REGISTRY_HELPER = REGISTRY_DIR / "prompt_studio_registry_helper.py" + +START_MARKER = " def fetch_json_for_registry(user: User) -> list[dict[str, Any]]:" + +# A registry row as the serializer emits it: `custom_tool` is a OneToOneField, +# which DRF renders as the raw PK -- the Prompt Studio tool_id. +PROMPT_REGISTRY_ID = "99999999-8888-7777-6666-555555555555" +CUSTOM_TOOL_ID = "11111111-2222-3333-4444-555555555555" + + +def _load_json_schema_key() -> Any: + spec = importlib.util.spec_from_file_location( + "_psr_constants_fetch", REGISTRY_DIR / "constants.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.JsonSchemaKey + + +JSON_SCHEMA_KEY = _load_json_schema_key() + + +def _real_fetch_json_for_registry(serialized_rows: list[dict[str, Any]]): + """Extract the real function, stubbing only the ORM/serializer boundary.""" + source = REGISTRY_HELPER.read_text() + if START_MARKER not in source: + pytest.fail( + f"Could not find {START_MARKER!r} in {REGISTRY_HELPER}. If it was " + "renamed or restructured, update this test rather than deleting it." + ) + body = source[source.index(START_MARKER) :] + end = body.find("\n @staticmethod") + if end != -1: + body = body[:end] + body = textwrap.dedent(body).replace( + "def fetch_json_for_registry(user: User) -> list[dict[str, Any]]:", + "def fetch_json_for_registry(user):", + ) + + class _Manager: + @staticmethod + def list_tools(user: Any) -> list[dict[str, Any]]: + return serialized_rows + + class _PromptStudioRegistry: + objects = _Manager() + + class _Serializer: + def __init__(self, instance: Any, many: bool = False) -> None: + self.data = instance + + class _InternalError(Exception): + pass + + class _Logger: + @staticmethod + def error(*args: Any, **kwargs: Any) -> None: + return None + + namespace: dict[str, Any] = { + "PromptStudioRegistry": _PromptStudioRegistry, + "PromptStudioRegistrySerializer": _Serializer, + "JsonSchemaKey": JSON_SCHEMA_KEY, + "InternalError": _InternalError, + "logger": _Logger, + "Any": Any, + } + exec(compile(body, str(REGISTRY_HELPER), "exec"), namespace) + return namespace["fetch_json_for_registry"] + + +def _row(**overrides: Any) -> dict[str, Any]: + row = { + "name": "Invoice extractor", + "description": "Extracts invoice fields", + "icon": "icon-data", + "prompt_registry_id": PROMPT_REGISTRY_ID, + "custom_tool": CUSTOM_TOOL_ID, + } + row.update(overrides) + return row + + +def test_listing_exposes_the_custom_tool_back_reference() -> None: + """The fix: callers can correlate an entry with the project that made it.""" + fetch = _real_fetch_json_for_registry([_row()]) + + (entry,) = fetch(user=object()) + + assert entry["custom_tool"] == CUSTOM_TOOL_ID, ( + "The registry listing must carry the Prompt Studio tool_id, otherwise " + "callers can only match on the ambiguous `name`" + ) + + +def test_function_name_remains_the_registry_id() -> None: + """`function_name` is the registry UUID, NOT the Prompt Studio tool_id. + + Pins the distinction that makes the back-reference necessary in the first + place, and guards against someone "fixing" the frontend's dead comparison by + redefining `function_name` instead -- which would break tool resolution. + """ + fetch = _real_fetch_json_for_registry([_row()]) + + (entry,) = fetch(user=object()) + + assert entry["function_name"] == PROMPT_REGISTRY_ID + assert entry["function_name"] != entry["custom_tool"] + + +def test_existing_keys_are_preserved() -> None: + """The change is additive -- current consumers must not break.""" + fetch = _real_fetch_json_for_registry([_row()]) + + (entry,) = fetch(user=object()) + + assert entry["name"] == "Invoice extractor" + assert entry["description"] == "Extracts invoice fields" + assert entry["icon"] == "icon-data" + + +def test_legacy_row_without_a_linked_project() -> None: + """``custom_tool`` is nullable, so unlinked legacy rows report None. + + They must not raise, and must not be dropped from the listing. + """ + fetch = _real_fetch_json_for_registry([_row(custom_tool=None)]) + + (entry,) = fetch(user=object()) + + assert entry["custom_tool"] is None + assert entry["name"] == "Invoice extractor" + + +def test_rows_do_not_bleed_into_each_other() -> None: + """The loop reuses a dict and resets it per row. + + A missing reset would smear one project's back-reference onto the next, + which is worse than the original bug: callers would correlate confidently + and wrongly. + """ + other_registry_id = "aaaaaaaa-1111-2222-3333-444444444444" + other_custom_tool = "bbbbbbbb-1111-2222-3333-444444444444" + fetch = _real_fetch_json_for_registry( + [ + _row(), + _row( + name="Receipt extractor", + description="Extracts receipt fields", + icon="other-icon", + prompt_registry_id=other_registry_id, + custom_tool=other_custom_tool, + ), + ] + ) + + first, second = fetch(user=object()) + + assert first["custom_tool"] == CUSTOM_TOOL_ID + assert second["custom_tool"] == other_custom_tool + assert first["function_name"] == PROMPT_REGISTRY_ID + assert second["function_name"] == other_registry_id + + +def test_two_projects_sharing_a_name_stay_distinguishable() -> None: + """The exact scenario the old projection could not represent.""" + duplicate_name = "Invoice extractor" + fetch = _real_fetch_json_for_registry( + [ + _row(name=duplicate_name), + _row( + name=duplicate_name, + prompt_registry_id="cccccccc-1111-2222-3333-444444444444", + custom_tool="dddddddd-1111-2222-3333-444444444444", + ), + ] + ) + + first, second = fetch(user=object()) + + assert first["name"] == second["name"] + assert first["custom_tool"] != second["custom_tool"], ( + "Two projects sharing a name must remain distinguishable by their " + "back-reference -- this is the whole point of the fix" + )