From 39166e1a32c179bb4f3f29bd9ce647b278325e54 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 2 Feb 2026 19:39:41 -0800 Subject: [PATCH 1/4] fix(sdk): enforce UUID agent IDs Validate agent_id as UUID in SDK (fail fast) and remove UUID5 fallback so server conflicts surface. Update docs/examples/tests/UI fixtures to use UUIDs and add validation test. --- docs/REFERENCE.md | 2 +- examples/README.md | 5 +- examples/agent_control_demo/demo_agent.py | 3 +- examples/agent_control_demo/setup_controls.py | 12 ++--- .../agent_control_demo/update_controls.py | 8 ++- examples/crewai/content_agent_protection.py | 2 +- examples/crewai/setup_content_controls.py | 6 +-- examples/customer_support_agent/README.md | 2 +- examples/customer_support_agent/demo.sh | 4 +- examples/customer_support_agent/run_demo.py | 9 ++-- .../setup_demo_controls.py | 8 ++- .../customer_support_agent/support_agent.py | 2 +- examples/langchain/setup_sql_controls.py | 6 +-- examples/langchain/sql_agent_protection.py | 2 +- models/README.md | 9 ++-- sdks/python/ARCHITECTURE.md | 10 ++-- sdks/python/README.md | 22 +++++--- sdks/python/src/agent_control/__init__.py | 54 +++++++++++-------- sdks/python/src/agent_control/agents.py | 22 ++++---- .../src/agent_control/control_decorators.py | 10 +++- sdks/python/src/agent_control/policies.py | 10 ++-- sdks/python/src/agent_control/validation.py | 20 +++++++ sdks/python/tests/README.md | 2 +- sdks/python/tests/conftest.py | 2 +- sdks/python/tests/test_control_decorators.py | 2 +- sdks/python/tests/test_init_validation.py | 10 ++++ ui/tests/agent-detail.spec.ts | 4 +- ui/tests/agent-stats.spec.ts | 8 +-- ui/tests/control-store.spec.ts | 4 +- ui/tests/evaluators/helpers.ts | 2 +- ui/tests/fixtures.ts | 12 ++--- 31 files changed, 163 insertions(+), 111 deletions(-) create mode 100644 sdks/python/src/agent_control/validation.py create mode 100644 sdks/python/tests/test_init_validation.py diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 0d2f2044..de913824 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -501,7 +501,7 @@ import agent_control agent_control.init( agent_name="my-agent", # Required: human-readable name - agent_id="my-agent-v1", # Required: unique identifier + agent_id="550e8400-e29b-41d4-a716-446655440000", # Required: UUID server_url="http://localhost:8000", # Optional: defaults to env var steps=[ # Optional: register available steps { diff --git a/examples/README.md b/examples/README.md index ff53a8a5..88febbc6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -92,7 +92,10 @@ import agent_control from agent_control import control, ControlViolationError # Initialize agent (connects to server, loads policy) -agent_control.init(agent_name="my-bot", agent_id="bot-123") +agent_control.init( + agent_name="my-bot", + agent_id="550e8400-e29b-41d4-a716-446655440000", +) # Apply the agent's assigned policy @control() diff --git a/examples/agent_control_demo/demo_agent.py b/examples/agent_control_demo/demo_agent.py index 268b5953..8a1e7cfe 100644 --- a/examples/agent_control_demo/demo_agent.py +++ b/examples/agent_control_demo/demo_agent.py @@ -44,7 +44,7 @@ # Configuration AGENT_NAME = "demo-chatbot" -AGENT_ID = "demo-chatbot-v1" +AGENT_ID = "672e50df-af4c-429f-965a-3d7f8262302f" SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") @@ -258,4 +258,3 @@ async def run_demo(): if __name__ == "__main__": asyncio.run(run_demo()) - diff --git a/examples/agent_control_demo/setup_controls.py b/examples/agent_control_demo/setup_controls.py index 30c5be35..cf4b6c73 100644 --- a/examples/agent_control_demo/setup_controls.py +++ b/examples/agent_control_demo/setup_controls.py @@ -34,7 +34,7 @@ # Configuration AGENT_NAME = "demo-chatbot" -AGENT_ID = "demo-chatbot-v1" +AGENT_ID = "672e50df-af4c-429f-965a-3d7f8262302f" SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") @@ -44,9 +44,8 @@ async def create_agent(client: AgentControlClient) -> str: print("STEP 1: Creating Agent") print("=" * 60) - # Generate a deterministic UUID from the agent ID - import uuid - agent_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, AGENT_ID) + # Use the provided UUID for the agent + agent_uuid = UUID(AGENT_ID) try: response = await client.http_client.post( @@ -440,9 +439,8 @@ async def main(): print(" cd server && make run") return - # Generate agent UUID - import uuid - agent_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, AGENT_ID)) + # Use the provided UUID for verification + agent_uuid = AGENT_ID # If verify-only mode, just run verification if args.verify_only: diff --git a/examples/agent_control_demo/update_controls.py b/examples/agent_control_demo/update_controls.py index 8430ed40..6c7ab3f9 100644 --- a/examples/agent_control_demo/update_controls.py +++ b/examples/agent_control_demo/update_controls.py @@ -19,15 +19,13 @@ import asyncio import os import sys -import uuid - # Add the SDK to path for development sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../sdks/python/src")) from agent_control import AgentControlClient # Configuration -AGENT_ID = "demo-chatbot-v1" +AGENT_ID = "672e50df-af4c-429f-965a-3d7f8262302f" SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") @@ -156,8 +154,8 @@ async def main(): parser.add_argument("--status", action="store_true", help="Show current status") args = parser.parse_args() - # Generate agent UUID - agent_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, AGENT_ID)) + # Use the provided UUID + agent_uuid = AGENT_ID print("\n" + "=" * 60) print("AGENT CONTROL DEMO: Update Controls") diff --git a/examples/crewai/content_agent_protection.py b/examples/crewai/content_agent_protection.py index f1d3f50f..118be45a 100644 --- a/examples/crewai/content_agent_protection.py +++ b/examples/crewai/content_agent_protection.py @@ -45,7 +45,7 @@ # It's a dependency of agent_control so should be available # --- Configuration --- -AGENT_ID = "support-crew-demo" +AGENT_ID = "989d84f0-9afe-4fb2-9e9e-e9d076271e29" AGENT_NAME = "Customer Support Crew" AGENT_DESCRIPTION = "Customer support crew with PII protection and access controls" diff --git a/examples/crewai/setup_content_controls.py b/examples/crewai/setup_content_controls.py index 612b15e1..94674e4d 100644 --- a/examples/crewai/setup_content_controls.py +++ b/examples/crewai/setup_content_controls.py @@ -10,11 +10,11 @@ import asyncio import os -import uuid +from uuid import UUID from agent_control import Agent, AgentControlClient, agents, controls, policies -AGENT_ID = "support-crew-demo" +AGENT_ID = "989d84f0-9afe-4fb2-9e9e-e9d076271e29" SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") @@ -22,7 +22,7 @@ async def setup_content_controls(): """Create PII protection and unauthorized access controls, policy, and assign to agent.""" async with AgentControlClient(base_url=SERVER_URL) as client: # 1. Register Agent - agent_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, AGENT_ID) + agent_uuid = UUID(AGENT_ID) agent = Agent( agent_id=agent_uuid, diff --git a/examples/customer_support_agent/README.md b/examples/customer_support_agent/README.md index c99e5f5d..98e88360 100644 --- a/examples/customer_support_agent/README.md +++ b/examples/customer_support_agent/README.md @@ -161,7 +161,7 @@ import agent_control agent_control.init( agent_name="Customer Support Agent", - agent_id="customer-support-agent", + agent_id="646d5dea-c2e6-4453-b446-7035482b38e4", agent_description="AI-powered customer support assistant", ) ``` diff --git a/examples/customer_support_agent/demo.sh b/examples/customer_support_agent/demo.sh index cada15cc..859c338a 100755 --- a/examples/customer_support_agent/demo.sh +++ b/examples/customer_support_agent/demo.sh @@ -222,9 +222,9 @@ setup_demo_controls() { python setup_demo_controls.py } -# Generate UUID5 from agent ID (matches Python's uuid.uuid5(uuid.NAMESPACE_DNS, agent_id)) +# Agent UUID used by the demo (must match support_agent.py) get_agent_uuid() { - python3 -c "import uuid; print(uuid.uuid5(uuid.NAMESPACE_DNS, 'customer-support-agent'))" + echo "646d5dea-c2e6-4453-b446-7035482b38e4" } show_observability_stats() { diff --git a/examples/customer_support_agent/run_demo.py b/examples/customer_support_agent/run_demo.py index 099eeb66..84eac08a 100644 --- a/examples/customer_support_agent/run_demo.py +++ b/examples/customer_support_agent/run_demo.py @@ -54,16 +54,13 @@ # Add parent directory to path for imports sys.path.insert(0, str(__file__).rsplit("/", 2)[0]) -# Agent ID used in support_agent.py -AGENT_ID = "customer-support-agent" +# Agent UUID used in support_agent.py +AGENT_ID = "646d5dea-c2e6-4453-b446-7035482b38e4" async def reset_agent(): """Reset the agent by removing its policy (which disconnects all controls).""" - import uuid - - # Generate the same UUID5 that the SDK generates - agent_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, AGENT_ID)) + agent_uuid = AGENT_ID server_url = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") logger.info(f"Resetting agent '{AGENT_ID}' (UUID: {agent_uuid})") diff --git a/examples/customer_support_agent/setup_demo_controls.py b/examples/customer_support_agent/setup_demo_controls.py index 3c4b686b..904f38ce 100644 --- a/examples/customer_support_agent/setup_demo_controls.py +++ b/examples/customer_support_agent/setup_demo_controls.py @@ -13,12 +13,10 @@ import asyncio import os -import uuid - from agent_control import Agent, AgentControlClient, agents, controls, policies # Same agent ID as in support_agent.py -AGENT_ID = "customer-support-agent" +AGENT_ID = "646d5dea-c2e6-4453-b446-7035482b38e4" AGENT_NAME = "Customer Support Agent" AGENT_DESCRIPTION = "AI-powered customer support assistant" @@ -228,8 +226,8 @@ async def setup_demo(quiet: bool = False): """Set up the demo agent with controls.""" - # Generate the same UUID5 that the SDK generates - agent_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, AGENT_ID)) + # Use the provided UUID (must match support_agent.py) + agent_uuid = AGENT_ID async with AgentControlClient(base_url=SERVER_URL, timeout=30.0) as client: # Check server health diff --git a/examples/customer_support_agent/support_agent.py b/examples/customer_support_agent/support_agent.py index 80267e71..74328a11 100644 --- a/examples/customer_support_agent/support_agent.py +++ b/examples/customer_support_agent/support_agent.py @@ -30,7 +30,7 @@ agent_control.init( agent_name="Customer Support Agent", - agent_id="customer-support-agent", + agent_id="646d5dea-c2e6-4453-b446-7035482b38e4", agent_description="AI-powered customer support assistant that helps with inquiries, " "searches knowledge bases, and creates support tickets.", agent_version="1.0.0", diff --git a/examples/langchain/setup_sql_controls.py b/examples/langchain/setup_sql_controls.py index f288193a..59bafda2 100644 --- a/examples/langchain/setup_sql_controls.py +++ b/examples/langchain/setup_sql_controls.py @@ -13,13 +13,13 @@ import asyncio import os import pathlib -import uuid +from uuid import UUID import requests from agent_control import Agent, AgentControlClient, agents, controls, policies -AGENT_ID = "sql-agent-demo" +AGENT_ID = "edf66504-0db5-4ee8-9e09-3ef37bbb8faa" SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") @@ -52,7 +52,7 @@ async def setup_sql_controls(): """Create SQL control, policy, and assign to agent.""" async with AgentControlClient(base_url=SERVER_URL) as client: # 1. Register Agent - agent_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, AGENT_ID) + agent_uuid = UUID(AGENT_ID) agent = Agent( agent_id=agent_uuid, diff --git a/examples/langchain/sql_agent_protection.py b/examples/langchain/sql_agent_protection.py index c90301e0..3e6dfb08 100644 --- a/examples/langchain/sql_agent_protection.py +++ b/examples/langchain/sql_agent_protection.py @@ -50,7 +50,7 @@ from langgraph.prebuilt import ToolNode # --- Configuration --- -AGENT_ID = "sql-agent-demo" +AGENT_ID = "edf66504-0db5-4ee8-9e09-3ef37bbb8faa" AGENT_NAME = "SQL Demo Agent" AGENT_DESCRIPTION = "SQL agent with server-side controls" USE_LOCAL_CONTROLS = os.getenv("AGENT_CONTROL_LOCAL_EVAL", "false").lower() == "true" diff --git a/models/README.md b/models/README.md index e813b79b..df73c129 100644 --- a/models/README.md +++ b/models/README.md @@ -50,7 +50,7 @@ from agent_control_models import Agent, Step # Create an agent agent = Agent( agent_name="Customer Support Bot", - agent_id="support-bot-v1", + agent_id="550e8400-e29b-41d4-a716-446655440000", agent_description="Handles customer inquiries", agent_version="1.0.0" ) @@ -129,7 +129,7 @@ Agent metadata and configuration. **Fields:** - `agent_name` (str): Human-readable agent name -- `agent_id` (str): Unique identifier +- `agent_id` (UUID): Unique identifier - `agent_description` (Optional[str]): Agent description - `agent_version` (Optional[str]): Agent version - `tools` (Optional[List[str]]): List of available tools @@ -198,7 +198,7 @@ from agent_control_models import Agent # Create with validation agent = Agent( agent_name="My Agent", - agent_id="my-agent-v1" + agent_id="550e8400-e29b-41d4-a716-446655440000" ) # Serialize to dict @@ -243,7 +243,7 @@ from agent_control_models import Agent # Add custom metadata agent = Agent( agent_name="Support Bot", - agent_id="support-v1", + agent_id="550e8400-e29b-41d4-a716-446655440000", metadata={ "team": "customer-success", "environment": "production", @@ -291,4 +291,3 @@ uv run pytest 4. **Version carefully**: Model changes affect both server and SDK 5. **Document fields**: Use Field's `description` parameter 6. **Use Optional appropriately**: Mark optional fields clearly - diff --git a/sdks/python/ARCHITECTURE.md b/sdks/python/ARCHITECTURE.md index 6af59b13..86625b10 100644 --- a/sdks/python/ARCHITECTURE.md +++ b/sdks/python/ARCHITECTURE.md @@ -66,7 +66,9 @@ async with agent_control.AgentControlClient() as client: ) # Get agent - agent_data = await agent_control.agents.get_agent(client, "agent-id") + agent_data = await agent_control.agents.get_agent( + client, "550e8400-e29b-41d4-a716-446655440000" + ) ``` ### `policies.py` - Policy Operations @@ -259,11 +261,13 @@ High-level convenience functions are provided in `__init__.py` for common operat ```python # Convenience: No need to manage client manually -agent_data = await agent_control.get_agent("agent-id") +agent_data = await agent_control.get_agent("550e8400-e29b-41d4-a716-446655440000") # Equivalent module-first approach: async with AgentControlClient() as client: - agent_data = await agent_control.agents.get_agent(client, "agent-id") + agent_data = await agent_control.agents.get_agent( + client, "550e8400-e29b-41d4-a716-446655440000" + ) ``` ### 4. API Stability diff --git a/sdks/python/README.md b/sdks/python/README.md index fb4e75d5..8607c60b 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -18,7 +18,7 @@ import agent_control # Initialize at the base of your agent agent_control.init( agent_name="My Customer Service Bot", - agent_id="csbot-prod-v1" + agent_id="550e8400-e29b-41d4-a716-446655440000" ) # Use the control decorator @@ -36,7 +36,7 @@ import agent_control agent_control.init( agent_name="Customer Service Bot", - agent_id="csbot-prod-v1", + agent_id="550e8400-e29b-41d4-a716-446655440000", agent_description="Handles customer inquiries and support", agent_version="2.1.0", server_url="http://localhost:8000", @@ -53,7 +53,10 @@ agent_control.init( One line to set up your agent with full protection: ```python -agent_control.init(agent_name="...", agent_id="...") +agent_control.init( + agent_name="...", + agent_id="550e8400-e29b-41d4-a716-446655440000", +) ``` This automatically: @@ -87,7 +90,7 @@ async with AgentControlClient() as client: # Evaluate a step result = await agent_control.evaluation.check_evaluation( client, - agent_uuid="your-agent-uuid", + agent_uuid="550e8400-e29b-41d4-a716-446655440000", step={"type": "llm_inference", "input": "User input here"}, stage="pre" ) @@ -114,7 +117,7 @@ from agent_control import control, ControlViolationError # Initialize agent_control.init( agent_name="Customer Support Bot", - agent_id="support-bot-v1", + agent_id="550e8400-e29b-41d4-a716-446655440000", agent_version="1.0.0" ) @@ -155,7 +158,7 @@ asyncio.run(main()) ```python def init( agent_name: str, - agent_id: str, + agent_id: str | UUID, agent_description: Optional[str] = None, agent_version: Optional[str] = None, server_url: Optional[str] = None, @@ -168,7 +171,7 @@ Initialize Agent Control with your agent's information. **Parameters:** - `agent_name`: Human-readable name -- `agent_id`: Unique identifier (user-defined) +- `agent_id`: UUID string (or UUID instance) - `agent_description`: Optional description - `agent_version`: Optional version string - `server_url`: Optional server URL (defaults to `AGENT_CONTROL_URL` env var) @@ -273,7 +276,10 @@ Basic usage: import agent_control from agent_control import control, ControlViolationError -agent_control.init(agent_name="...", agent_id="...") +agent_control.init( + agent_name="...", + agent_id="550e8400-e29b-41d4-a716-446655440000", +) @control() async def handle(message: str): diff --git a/sdks/python/src/agent_control/__init__.py b/sdks/python/src/agent_control/__init__.py index fbda576a..b2b84995 100644 --- a/sdks/python/src/agent_control/__init__.py +++ b/sdks/python/src/agent_control/__init__.py @@ -10,7 +10,7 @@ # Initialize at the base of your agent file agent_control.init( agent_name="my-customer-service-bot", - agent_id="csbot-prod-v1" + agent_id="550e8400-e29b-41d4-a716-446655440000" ) # Apply server-defined controls using the decorator @@ -40,7 +40,10 @@ async def process(input: str) -> str: from typing import TYPE_CHECKING, Any, Literal, TypeVar from uuid import UUID +import httpx + from . import agents, controls, evaluation, evaluators, policies +from .validation import ensure_uuid # Import client and operations modules from .client import AgentControlClient @@ -188,7 +191,7 @@ def __init__( def init( agent_name: str, - agent_id: str, + agent_id: str | UUID, agent_description: str | None = None, agent_version: str | None = None, server_url: str | None = None, @@ -212,9 +215,7 @@ def init( Args: agent_name: Human-readable name for your agent (e.g., "Customer Service Bot") - agent_id: Unique identifier for your agent. Can be: - - A UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000") - - Any string (e.g., "csbot-prod-v1") - will be converted to UUID + agent_id: Unique identifier for your agent (UUID string or UUID instance) agent_description: Optional description of what your agent does agent_version: Optional version string (e.g., "1.0.0") server_url: Optional server URL (defaults to AGENT_CONTROL_URL env var @@ -236,7 +237,7 @@ def init( agent_control.init( agent_name="Customer Service Bot", - agent_id="csbot-prod-v1", + agent_id="550e8400-e29b-41d4-a716-446655440000", agent_description="Handles customer inquiries and support tickets", agent_version="2.1.0", steps=[ @@ -264,25 +265,19 @@ async def handle(message: str): if not agent_id: raise ValueError( "The 'agent_id' argument is required for initialization.\n" - "Please provide a unique string identifier for your agent, e.g.:\n" - ' agent_control.init(agent_name="my-agent", agent_id="my-agent-v1")' + "Please provide a valid UUID string for your agent, e.g.:\n" + ' agent_control.init(agent_name="my-agent", ' + 'agent_id="550e8400-e29b-41d4-a716-446655440000")' ) + # Validate agent_id is a UUID (string or UUID instance) + _agent_uuid = ensure_uuid(agent_id) + # Configure logging if provided (do this early before any logging happens) if log_config: configure_logging(log_config) # Create agent instance with metadata - # Convert agent_id to UUID (accept UUID string or generate from regular string) - try: - _agent_uuid = UUID(agent_id) - except ValueError: - # If not a valid UUID, generate UUID5 (namespace-based, deterministic) - # Using DNS namespace for consistency - import uuid - _agent_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, agent_id) - logger.info("Generated UUID5 %s from agent_id '%s'", _agent_uuid, agent_id) - _current_agent = Agent( agent_id=_agent_uuid, agent_name=agent_name, @@ -331,6 +326,9 @@ async def register() -> list[dict[str, Any]] | None: logger.debug("Registered %d step(s)", len(steps)) return controls + except httpx.HTTPStatusError: + # Surface API errors like UUID conflicts + raise except Exception as e: logger.error("Failed to register agent: %s", e, exc_info=True) return None @@ -370,6 +368,9 @@ def run_in_thread() -> None: server_controls = loop.run_until_complete(register()) loop.close() + except httpx.HTTPStatusError: + # Surface server-side errors (e.g., 409 UUID conflicts) + raise except Exception as e: logger.error("Could not connect to server: %s", e, exc_info=True) logger.info("Will use local controls if available") @@ -397,7 +398,7 @@ def run_in_thread() -> None: async def get_agent( - agent_id: str, + agent_id: str | UUID, server_url: str | None = None, api_key: str | None = None, ) -> dict[str, Any]: @@ -405,7 +406,7 @@ async def get_agent( Get agent details from the server by ID. Args: - agent_id: UUID or string identifier of the agent + agent_id: UUID string or UUID instance server_url: Optional server URL (defaults to AGENT_CONTROL_URL env var) api_key: Optional API key for authentication (defaults to AGENT_CONTROL_API_KEY env var) @@ -423,7 +424,9 @@ async def get_agent( # Fetch agent from server async def main(): - agent_data = await agent_control.get_agent("bot-123") + agent_data = await agent_control.get_agent( + "550e8400-e29b-41d4-a716-446655440000" + ) print(f"Agent: {agent_data['agent']['agent_name']}") print(f"Steps: {len(agent_data['steps'])}") @@ -431,7 +434,9 @@ async def main(): # Or using the client directly async with agent_control.AgentControlClient() as client: - agent_data = await agent_control.agents.get_agent(client, "bot-123") + agent_data = await agent_control.agents.get_agent( + client, "550e8400-e29b-41d4-a716-446655440000" + ) """ _final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000' @@ -447,7 +452,10 @@ def current_agent() -> Agent | None: Current Agent instance or None if not initialized Example: - agent_control.init(agent_name="My Bot", agent_id="bot-123") + agent_control.init( + agent_name="My Bot", + agent_id="550e8400-e29b-41d4-a716-446655440000", + ) agent = agent_control.current_agent() print(agent.agent_name) # "My Bot" """ diff --git a/sdks/python/src/agent_control/agents.py b/sdks/python/src/agent_control/agents.py index dec6b4a9..b7b3fd33 100644 --- a/sdks/python/src/agent_control/agents.py +++ b/sdks/python/src/agent_control/agents.py @@ -6,6 +6,7 @@ from agent_control_engine import ensure_evaluators_discovered from .client import AgentControlClient +from .validation import ensure_uuid_str # Import models if available try: @@ -74,14 +75,14 @@ async def register_agent( async def get_agent( client: AgentControlClient, - agent_id: str + agent_id: str | UUID ) -> dict[str, Any]: """ Get agent details by ID from the server. Args: client: AgentControlClient instance - agent_id: UUID or string identifier of the agent + agent_id: UUID string or UUID instance Returns: Dictionary containing: @@ -97,7 +98,8 @@ async def get_agent( print(f"Agent: {agent_data['agent']['agent_name']}") print(f"Steps: {len(agent_data['steps'])}") """ - response = await client.http_client.get(f"/api/v1/agents/{agent_id}") + agent_id_str = ensure_uuid_str(agent_id) + response = await client.http_client.get(f"/api/v1/agents/{agent_id_str}") response.raise_for_status() return cast(dict[str, Any], response.json()) @@ -146,14 +148,14 @@ async def list_agents( async def get_agent_policy( client: AgentControlClient, - agent_id: str, + agent_id: str | UUID, ) -> dict[str, Any]: """ Get the policy assigned to an agent. Args: client: AgentControlClient instance - agent_id: UUID or string identifier of the agent + agent_id: UUID string or UUID instance Returns: Dictionary containing: @@ -167,21 +169,22 @@ async def get_agent_policy( policy = await get_agent_policy(client, agent_id) print(f"Policy ID: {policy['policy_id']}") """ - response = await client.http_client.get(f"/api/v1/agents/{agent_id}/policy") + agent_id_str = ensure_uuid_str(agent_id) + response = await client.http_client.get(f"/api/v1/agents/{agent_id_str}/policy") response.raise_for_status() return cast(dict[str, Any], response.json()) async def remove_agent_policy( client: AgentControlClient, - agent_id: str, + agent_id: str | UUID, ) -> dict[str, Any]: """ Remove the policy assignment from an agent. Args: client: AgentControlClient instance - agent_id: UUID or string identifier of the agent + agent_id: UUID string or UUID instance Returns: Dictionary containing success flag/details @@ -189,6 +192,7 @@ async def remove_agent_policy( Raises: httpx.HTTPError: If request fails or agent has no policy """ - response = await client.http_client.delete(f"/api/v1/agents/{agent_id}/policy") + agent_id_str = ensure_uuid_str(agent_id) + response = await client.http_client.delete(f"/api/v1/agents/{agent_id_str}/policy") response.raise_for_status() return cast(dict[str, Any], response.json()) diff --git a/sdks/python/src/agent_control/control_decorators.py b/sdks/python/src/agent_control/control_decorators.py index 247edc20..a2618054 100644 --- a/sdks/python/src/agent_control/control_decorators.py +++ b/sdks/python/src/agent_control/control_decorators.py @@ -11,7 +11,10 @@ Usage: import agent_control - agent_control.init(agent_name="my-agent", agent_id="agent-123") + agent_control.init( + agent_name="my-agent", + agent_id="550e8400-e29b-41d4-a716-446655440000", + ) # Apply the agent's assigned policy @agent_control.control() @@ -502,7 +505,10 @@ def control(policy: str | None = None) -> Callable[[F], F]: import agent_control # Initialize agent (connects to server, loads policy) - agent_control.init(agent_name="my-bot", agent_id="bot-123") + agent_control.init( + agent_name="my-bot", + agent_id="550e8400-e29b-41d4-a716-446655440000", + ) # Apply the agent's policy (all controls) @agent_control.apply_control() diff --git a/sdks/python/src/agent_control/policies.py b/sdks/python/src/agent_control/policies.py index 9c67be50..dab7ecaa 100644 --- a/sdks/python/src/agent_control/policies.py +++ b/sdks/python/src/agent_control/policies.py @@ -1,8 +1,10 @@ """Policy management operations for Agent Control SDK.""" from typing import Any, cast +from uuid import UUID from .client import AgentControlClient +from .validation import ensure_uuid_str async def create_policy( @@ -150,7 +152,7 @@ async def list_policy_controls( async def assign_policy_to_agent( client: AgentControlClient, - agent_id: str, + agent_id: str | UUID, policy_id: int ) -> dict[str, Any]: """ @@ -160,7 +162,7 @@ async def assign_policy_to_agent( Args: client: AgentControlClient instance - agent_id: UUID or string identifier of the agent + agent_id: UUID string or UUID instance policy_id: ID of the policy to assign Returns: @@ -170,9 +172,9 @@ async def assign_policy_to_agent( httpx.HTTPError: If request fails HTTPException 404: Agent or policy not found """ + agent_id_str = ensure_uuid_str(agent_id) response = await client.http_client.post( - f"/api/v1/agents/{agent_id}/policy/{policy_id}" + f"/api/v1/agents/{agent_id_str}/policy/{policy_id}" ) response.raise_for_status() return cast(dict[str, Any], response.json()) - diff --git a/sdks/python/src/agent_control/validation.py b/sdks/python/src/agent_control/validation.py new file mode 100644 index 00000000..aaf95ff3 --- /dev/null +++ b/sdks/python/src/agent_control/validation.py @@ -0,0 +1,20 @@ +"""Validation helpers for SDK inputs.""" + +from __future__ import annotations + +from uuid import UUID + + +def ensure_uuid(value: str | UUID, field_name: str = "agent_id") -> UUID: + """Return a UUID instance or raise ValueError for invalid UUID strings.""" + if isinstance(value, UUID): + return value + try: + return UUID(str(value)) + except (TypeError, ValueError, AttributeError) as exc: + raise ValueError(f"{field_name} must be a valid UUID string") from exc + + +def ensure_uuid_str(value: str | UUID, field_name: str = "agent_id") -> str: + """Return a UUID string or raise ValueError for invalid UUID strings.""" + return str(ensure_uuid(value, field_name=field_name)) diff --git a/sdks/python/tests/README.md b/sdks/python/tests/README.md index 7282d5a6..71e690d7 100644 --- a/sdks/python/tests/README.md +++ b/sdks/python/tests/README.md @@ -152,7 +152,7 @@ PASSED ### Function-Scoped Fixtures - `client`: Authenticated AgentProtectClient instance - `unique_name`: Unique name generator for test resources -- `test_agent_id`: Unique agent ID for testing +- `test_agent_id`: Unique agent UUID for testing - `test_agent`: Registered test agent - `test_policy`: Created test policy - `test_control`: Created test control diff --git a/sdks/python/tests/conftest.py b/sdks/python/tests/conftest.py index 6c45b574..d5bd090f 100644 --- a/sdks/python/tests/conftest.py +++ b/sdks/python/tests/conftest.py @@ -102,7 +102,7 @@ def unique_name() -> str: @pytest.fixture def test_agent_id() -> str: """Generate a unique agent ID for testing.""" - return f"test-agent-{uuid.uuid4().hex[:12]}" + return str(uuid.uuid4()) @pytest_asyncio.fixture diff --git a/sdks/python/tests/test_control_decorators.py b/sdks/python/tests/test_control_decorators.py index 762faef1..0d26d924 100644 --- a/sdks/python/tests/test_control_decorators.py +++ b/sdks/python/tests/test_control_decorators.py @@ -15,7 +15,7 @@ def mock_agent(): """Create a mock agent.""" agent = MagicMock() - agent.agent_id = "test-agent-uuid-123" + agent.agent_id = "550e8400-e29b-41d4-a716-446655440000" return agent diff --git a/sdks/python/tests/test_init_validation.py b/sdks/python/tests/test_init_validation.py new file mode 100644 index 00000000..4d2c58b3 --- /dev/null +++ b/sdks/python/tests/test_init_validation.py @@ -0,0 +1,10 @@ +"""Validation tests for agent_control.init().""" + +import pytest + +import agent_control + + +def test_init_rejects_invalid_uuid() -> None: + with pytest.raises(ValueError, match="agent_id must be a valid UUID"): + agent_control.init(agent_name="Invalid UUID Agent", agent_id="not-a-uuid") diff --git a/ui/tests/agent-detail.spec.ts b/ui/tests/agent-detail.spec.ts index b65f6b61..0b1ec648 100644 --- a/ui/tests/agent-detail.spec.ts +++ b/ui/tests/agent-detail.spec.ts @@ -3,7 +3,7 @@ import type { AgentControlsResponse, GetAgentResponse } from "@/core/api/types"; import { expect, mockData, test } from "./fixtures"; test.describe("Agent Detail Page", () => { - const agentId = "agent-1"; + const agentId = "9c15431d-c252-4c1b-80e0-d49ecda4f4b5"; const agentUrl = `/agents/${agentId}`; // Type-safe access to mock agent data @@ -279,7 +279,7 @@ test.describe("Agent Detail - Empty State", () => { }); }); - await page.goto("/agents/agent-1"); + await page.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); // Check for empty state message await expect(page.getByText("No controls configured")).toBeVisible(); diff --git a/ui/tests/agent-stats.spec.ts b/ui/tests/agent-stats.spec.ts index 30986422..5609862a 100644 --- a/ui/tests/agent-stats.spec.ts +++ b/ui/tests/agent-stats.spec.ts @@ -3,7 +3,7 @@ import { expect, mockData, mockRoutes, test } from "./fixtures"; test.describe("Agent Stats Tab", () => { test.beforeEach(async ({ mockedPage }) => { // Navigate to agent detail page - await mockedPage.goto("/agents/agent-1"); + await mockedPage.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); // Wait for the page to load await expect(mockedPage.getByText("Customer Support Bot")).toBeVisible(); }); @@ -148,7 +148,7 @@ test.describe("Agent Stats Tab - Empty State", () => { await mockRoutes.stats(page, { data: mockData.emptyStats }); // Navigate to agent detail page - await page.goto("/agents/agent-1"); + await page.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); await expect(page.getByText("Customer Support Bot")).toBeVisible(); // Navigate to stats tab @@ -228,7 +228,7 @@ test.describe("Agent Stats Tab - Refetch Flow", () => { }); // Navigate to agent detail page - await page.goto("/agents/agent-1"); + await page.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); await expect(page.getByText("Customer Support Bot")).toBeVisible(); // Navigate to stats tab @@ -258,7 +258,7 @@ test.describe("Agent Stats Tab - Error State", () => { await mockRoutes.stats(page, { error: "Internal server error", status: 500 }); // Navigate to agent detail page - await page.goto("/agents/agent-1"); + await page.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); await expect(page.getByText("Customer Support Bot")).toBeVisible(); // Navigate to stats tab diff --git a/ui/tests/control-store.spec.ts b/ui/tests/control-store.spec.ts index a0fc1ef7..d564a309 100644 --- a/ui/tests/control-store.spec.ts +++ b/ui/tests/control-store.spec.ts @@ -2,7 +2,7 @@ import { expect, mockData, test } from "./fixtures"; test.describe("Control Store Modal", () => { - const agentUrl = "/agents/agent-1"; + const agentUrl = "/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"; test.beforeEach(async ({ mockedPage }) => { await mockedPage.goto(agentUrl); @@ -139,7 +139,7 @@ test.describe("Control Store - Loading States", () => { }); }); - await page.goto("/agents/agent-1"); + await page.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); // Open the control store modal await page.getByTestId("add-control-button").first().click(); diff --git a/ui/tests/evaluators/helpers.ts b/ui/tests/evaluators/helpers.ts index 0194e31f..3c811765 100644 --- a/ui/tests/evaluators/helpers.ts +++ b/ui/tests/evaluators/helpers.ts @@ -4,7 +4,7 @@ import { expect, type Page } from "@playwright/test"; -const AGENT_URL = "/agents/agent-1"; +const AGENT_URL = "/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"; /** * Opens the control store and selects an evaluator to create a new control diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts index 305f6148..dca333bf 100644 --- a/ui/tests/fixtures.ts +++ b/ui/tests/fixtures.ts @@ -18,7 +18,7 @@ import type { StatsResponse } from "@/core/hooks/query-hooks/use-agent-stats"; // Satisfies ensures type checking while allowing inference of literal types const agentsList: AgentSummary[] = [ { - agent_id: "agent-1", + agent_id: "9c15431d-c252-4c1b-80e0-d49ecda4f4b5", agent_name: "Customer Support Bot", policy_id: 1, created_at: "2024-01-01T00:00:00Z", @@ -27,7 +27,7 @@ const agentsList: AgentSummary[] = [ active_controls_count: 3, }, { - agent_id: "agent-2", + agent_id: "b286abfe-6363-4593-85e0-fa71478f1906", agent_name: "Data Analysis Agent", policy_id: 2, created_at: "2024-01-02T00:00:00Z", @@ -36,7 +36,7 @@ const agentsList: AgentSummary[] = [ active_controls_count: 2, }, { - agent_id: "agent-3", + agent_id: "ab97d181-1b29-4f87-b6e8-c606c7c4ad79", agent_name: "Code Review Assistant", policy_id: 3, created_at: "2024-01-03T00:00:00Z", @@ -58,7 +58,7 @@ const agentsResponse: ListAgentsResponse = { const agentResponse: GetAgentResponse = { agent: { - agent_id: "agent-1", + agent_id: "9c15431d-c252-4c1b-80e0-d49ecda4f4b5", agent_name: "Customer Support Bot", agent_description: "Handles customer inquiries and support tickets", agent_created_at: "2024-01-01T00:00:00Z", @@ -207,7 +207,7 @@ const evaluatorsResponse: EvaluatorsResponse = { }; const statsResponse: StatsResponse = { - agent_uuid: "agent-1", + agent_uuid: "9c15431d-c252-4c1b-80e0-d49ecda4f4b5", time_range: "1h", stats: [ { @@ -266,7 +266,7 @@ const statsResponse: StatsResponse = { }; const emptyStatsResponse: StatsResponse = { - agent_uuid: "agent-1", + agent_uuid: "9c15431d-c252-4c1b-80e0-d49ecda4f4b5", time_range: "1h", stats: [], total_executions: 0, From 062836e1d90f59e3370f6bcce3b6d02bf0791d96 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 2 Feb 2026 20:46:34 -0800 Subject: [PATCH 2/4] fix(tests): avoid init agent UUID conflicts Use unique init agent names in SDK integration tests and let ruff normalize import order after UUID validation changes. --- sdks/python/src/agent_control/__init__.py | 2 +- sdks/python/tests/test_integration_agents.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdks/python/src/agent_control/__init__.py b/sdks/python/src/agent_control/__init__.py index b2b84995..9d2d342d 100644 --- a/sdks/python/src/agent_control/__init__.py +++ b/sdks/python/src/agent_control/__init__.py @@ -43,7 +43,6 @@ async def process(input: str) -> str: import httpx from . import agents, controls, evaluation, evaluators, policies -from .validation import ensure_uuid # Import client and operations modules from .client import AgentControlClient @@ -72,6 +71,7 @@ async def process(input: str) -> str: is_otel_available, with_trace, ) +from .validation import ensure_uuid # Module logger logger = get_logger(__name__) diff --git a/sdks/python/tests/test_integration_agents.py b/sdks/python/tests/test_integration_agents.py index 35fab3b6..f3208a53 100644 --- a/sdks/python/tests/test_integration_agents.py +++ b/sdks/python/tests/test_integration_agents.py @@ -177,7 +177,7 @@ async def test_init_function_workflow( """ # Initialize agent agent = agent_control.init( - agent_name="Init Test Agent", + agent_name=f"Init Test Agent {test_agent_id}", agent_id=test_agent_id, agent_description="Testing init function", agent_version="1.0.0", @@ -189,7 +189,7 @@ async def test_init_function_workflow( # Verify agent instance assert agent is not None - assert agent.agent_name == "Init Test Agent" + assert agent.agent_name == f"Init Test Agent {test_agent_id}" assert hasattr(agent, "agent_id") # Verify current_agent() From a45ce73ef37120bc81bd72b7a6cbcaf9a634c8ca Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 2 Feb 2026 20:56:25 -0800 Subject: [PATCH 3/4] test(sdk): add UUID validation coverage Add unit and behavioral tests for UUID validation helpers, fail-fast SDK agent_id handling, and init() conflict surfacing to improve coverage of the new UUID enforcement. --- sdks/python/tests/test_agent_id_validation.py | 76 +++++++++++++++++++ sdks/python/tests/test_init_conflict.py | 37 +++++++++ sdks/python/tests/test_validation.py | 42 ++++++++++ 3 files changed, 155 insertions(+) create mode 100644 sdks/python/tests/test_agent_id_validation.py create mode 100644 sdks/python/tests/test_init_conflict.py create mode 100644 sdks/python/tests/test_validation.py diff --git a/sdks/python/tests/test_agent_id_validation.py b/sdks/python/tests/test_agent_id_validation.py new file mode 100644 index 00000000..488538ac --- /dev/null +++ b/sdks/python/tests/test_agent_id_validation.py @@ -0,0 +1,76 @@ +"""SDK agent_id validation behavior tests.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from agent_control import agents, policies + + +class DummyResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return {"ok": True} + + +@pytest.mark.asyncio +async def test_get_agent_rejects_invalid_uuid() -> None: + client = MagicMock() + client.http_client = MagicMock() + client.http_client.get = AsyncMock() + + with pytest.raises(ValueError, match="agent_id must be a valid UUID"): + await agents.get_agent(client, "not-a-uuid") + + client.http_client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_agent_policy_rejects_invalid_uuid() -> None: + client = MagicMock() + client.http_client = MagicMock() + client.http_client.get = AsyncMock() + + with pytest.raises(ValueError, match="agent_id must be a valid UUID"): + await agents.get_agent_policy(client, "not-a-uuid") + + client.http_client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_remove_agent_policy_rejects_invalid_uuid() -> None: + client = MagicMock() + client.http_client = MagicMock() + client.http_client.delete = AsyncMock() + + with pytest.raises(ValueError, match="agent_id must be a valid UUID"): + await agents.remove_agent_policy(client, "not-a-uuid") + + client.http_client.delete.assert_not_called() + + +@pytest.mark.asyncio +async def test_assign_policy_rejects_invalid_uuid() -> None: + client = MagicMock() + client.http_client = MagicMock() + client.http_client.post = AsyncMock() + + with pytest.raises(ValueError, match="agent_id must be a valid UUID"): + await policies.assign_policy_to_agent(client, "not-a-uuid", policy_id=1) + + client.http_client.post.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_agent_accepts_uuid_object() -> None: + client = MagicMock() + client.http_client = MagicMock() + client.http_client.get = AsyncMock(return_value=DummyResponse()) + + agent_id = uuid4() + await agents.get_agent(client, agent_id) + + client.http_client.get.assert_awaited_once_with(f"/api/v1/agents/{agent_id}") diff --git a/sdks/python/tests/test_init_conflict.py b/sdks/python/tests/test_init_conflict.py new file mode 100644 index 00000000..3e712e01 --- /dev/null +++ b/sdks/python/tests/test_init_conflict.py @@ -0,0 +1,37 @@ +"""Tests that init() surfaces server-side conflicts.""" + +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import httpx +import pytest + +import agent_control + + +def _make_conflict_error() -> httpx.HTTPStatusError: + request = httpx.Request("POST", "http://localhost:8000/api/v1/agents/initAgent") + response = httpx.Response(409, request=request) + return httpx.HTTPStatusError( + "Client error '409 Conflict' for url 'http://localhost:8000/api/v1/agents/initAgent'", + request=request, + response=response, + ) + + +def test_init_surfaces_uuid_conflict() -> None: + conflict = _make_conflict_error() + + with patch( + "agent_control.__init__.AgentControlClient.health_check", + new=AsyncMock(return_value={"status": "healthy"}), + ), patch( + "agent_control.__init__.agents.register_agent", + new=AsyncMock(side_effect=conflict), + ): + with pytest.raises(httpx.HTTPStatusError): + agent_control.init( + agent_name="Init Conflict Agent", + agent_id=str(uuid4()), + agent_description="Testing init conflict handling", + ) diff --git a/sdks/python/tests/test_validation.py b/sdks/python/tests/test_validation.py new file mode 100644 index 00000000..1564f78b --- /dev/null +++ b/sdks/python/tests/test_validation.py @@ -0,0 +1,42 @@ +"""Unit tests for SDK validation helpers.""" + +from uuid import UUID, uuid4 + +import pytest + +from agent_control.validation import ensure_uuid, ensure_uuid_str + + +def test_ensure_uuid_accepts_uuid_instance() -> None: + value = uuid4() + assert ensure_uuid(value) == value + + +def test_ensure_uuid_accepts_uuid_string() -> None: + value = uuid4() + assert ensure_uuid(str(value)) == value + + +def test_ensure_uuid_rejects_invalid_value() -> None: + with pytest.raises(ValueError, match="agent_id must be a valid UUID string"): + ensure_uuid("not-a-uuid") + + +def test_ensure_uuid_respects_field_name() -> None: + with pytest.raises(ValueError, match="agent_uuid must be a valid UUID string"): + ensure_uuid("not-a-uuid", field_name="agent_uuid") + + +def test_ensure_uuid_str_returns_string() -> None: + value = uuid4() + assert ensure_uuid_str(value) == str(value) + + +def test_ensure_uuid_str_accepts_uuid_string() -> None: + value = str(uuid4()) + assert ensure_uuid_str(value) == str(UUID(value)) + + +def test_ensure_uuid_str_rejects_invalid_value() -> None: + with pytest.raises(ValueError, match="agent_id must be a valid UUID string"): + ensure_uuid_str("not-a-uuid") From 6f7c19e9154ddba60f30dfdfc59cd54b178b6aec Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 2 Feb 2026 21:50:57 -0800 Subject: [PATCH 4/4] Fix ui ci --- ui/src/core/hooks/use-query-param.ts | 54 +++++---- ui/tests/control-store.spec.ts | 157 ++++----------------------- ui/tests/evaluators/helpers.ts | 10 +- ui/tests/fixtures.ts | 10 +- ui/tests/home.spec.ts | 11 +- ui/tests/search-input.spec.ts | 26 ++--- 6 files changed, 80 insertions(+), 188 deletions(-) diff --git a/ui/src/core/hooks/use-query-param.ts b/ui/src/core/hooks/use-query-param.ts index e2c33b33..c462184b 100644 --- a/ui/src/core/hooks/use-query-param.ts +++ b/ui/src/core/hooks/use-query-param.ts @@ -1,5 +1,5 @@ import { useRouter } from "next/router"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useState } from "react"; interface UseQueryParamOptions { /** Default value when param is not in URL */ @@ -28,36 +28,50 @@ export function useQueryParam( const { defaultValue = "", shallow = true } = options; const router = useRouter(); - // Derive value directly from router.query (no local state needed) - const value = useMemo(() => { - if (!router.isReady) return defaultValue; - const urlValue = router.query[key]; - return typeof urlValue === "string" ? urlValue : defaultValue; - }, [router.isReady, router.query, key, defaultValue]); + const [value, setValueState] = useState(defaultValue); - // Track if we're currently updating to prevent loops - const isUpdatingRef = useRef(false); + const readValueFromLocation = useCallback(() => { + if (typeof window === "undefined") return defaultValue; + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get(key) ?? defaultValue; + }, [key, defaultValue]); - // Update URL when value changes - const setValue = useCallback( + const updateUrl = useCallback( (newValue: string) => { - if (!router.isReady || isUpdatingRef.current) return; - - isUpdatingRef.current = true; + if (typeof window === "undefined") return; - const query = { ...router.query }; + const url = new URL(window.location.href); if (newValue) { - query[key] = newValue; + url.searchParams.set(key, newValue); } else { - delete query[key]; + url.searchParams.delete(key); } - router.replace({ pathname: router.pathname, query }, undefined, { shallow }).finally(() => { - isUpdatingRef.current = false; - }); + const nextPath = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState(window.history.state, "", nextPath); + + if (router.isReady) { + void router.replace(nextPath, undefined, { shallow }); + } }, [router, key, shallow] ); + // Update URL when value changes + const setValue = useCallback( + (newValue: string) => { + setValueState(newValue); + updateUrl(newValue); + }, + [updateUrl] + ); + + useEffect(() => { + const nextValue = readValueFromLocation(); + if (nextValue !== value) { + setValueState(nextValue); + } + }, [readValueFromLocation, router.asPath, value]); + return [value, setValue]; } diff --git a/ui/tests/control-store.spec.ts b/ui/tests/control-store.spec.ts index 647f4543..a89038ab 100644 --- a/ui/tests/control-store.spec.ts +++ b/ui/tests/control-store.spec.ts @@ -7,16 +7,6 @@ const agentUrl = "/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"; async function openControlStoreModal(page: Page) { await page.goto(agentUrl); await page.getByTestId("add-control-button").first().click(); - const modal = page - .getByRole("dialog") - .filter({ hasText: "Browse existing controls or create a new one" }); - await expect(modal).toBeVisible(); - return modal; -} - -async function openAddNewControlModal(page: Page) { - const controlStoreModal = await openControlStoreModal(page); - await controlStoreModal.getByTestId("footer-new-control-button").click(); const modal = page .getByRole("dialog") .filter({ hasText: "Browse and add controls to your agent" }); @@ -29,133 +19,25 @@ test.describe("Control Store Modal", () => { const modal = await openControlStoreModal(mockedPage); await expect(modal.getByRole("heading", { name: "Control store" })).toBeVisible(); await expect( - modal.getByText("Browse existing controls or create a new one") - ).toBeVisible(); - }); - - test("displays controls table with available controls", async ({ - mockedPage, - }) => { - const modal = await openControlStoreModal(mockedPage); - - await expect(modal.getByRole("columnheader", { name: "Name" })).toBeVisible(); - await expect(modal.getByRole("columnheader", { name: "Description" })).toBeVisible(); - await expect(modal.getByRole("columnheader", { name: "Enabled" })).toBeVisible(); - await expect(modal.getByRole("columnheader", { name: "Used by" })).toBeVisible(); - - for (const control of mockData.listControls.controls) { - await expect(modal.getByText(control.name, { exact: true })).toBeVisible(); - } - }); - - test("displays agent links in Used by column", async ({ mockedPage }) => { - const modal = await openControlStoreModal(mockedPage); - - // PII Detection is used by Customer Support Bot - const agentLink = modal.getByRole("link", { name: "Customer Support Bot" }).first(); - await expect(agentLink).toBeVisible(); - // Link includes query param to filter by control name - await expect(agentLink).toHaveAttribute("href", "/agents/agent-1?q=PII%20Detection"); - }); - - test("can search for controls", async ({ mockedPage }) => { - const modal = await openControlStoreModal(mockedPage); - const searchInput = modal.getByPlaceholder("Search controls..."); - - // Fill search and wait for debounced API request (300ms debounce) - const searchPromise = mockedPage.waitForRequest((req) => - req.url().includes("/api/v1/controls") && req.url().includes("name=SQL") - ); - await searchInput.fill("SQL"); - await searchPromise; - - await expect(modal.getByText("SQL Injection Guard", { exact: true })).toBeVisible(); - await expect( - modal.getByText("PII Detection", { exact: true }) - ).not.toBeVisible(); - }); - - test("shows empty state when search has no results", async ({ mockedPage }) => { - const modal = await openControlStoreModal(mockedPage); - const searchInput = modal.getByPlaceholder("Search controls..."); - - // Fill search and wait for debounced API request - const searchPromise = mockedPage.waitForRequest((req) => - req.url().includes("/api/v1/controls") && req.url().includes("name=NonexistentControl") - ); - await searchInput.fill("NonexistentControl"); - await searchPromise; - - await expect(modal.getByText("No controls found")).toBeVisible(); - }); - - test("can close modal with X button", async ({ mockedPage }) => { - const modal = await openControlStoreModal(mockedPage); - await modal.getByTestId("close-control-store-modal-button").click(); - await expect( - mockedPage.getByText("Browse existing controls or create a new one") - ).not.toBeVisible(); - }); - - test("Use button opens create control modal", async ({ mockedPage }) => { - const modal = await openControlStoreModal(mockedPage); - const tableRow = modal.locator("tbody tr").first(); - await tableRow.getByTestId("use-control-button").click(); - - await expect(mockedPage.getByRole("heading", { name: "Create Control" })).toBeVisible(); - }); - - test("Use button pre-fills control name and evaluator config", async ({ mockedPage }) => { - const modal = await openControlStoreModal(mockedPage); - const targetRow = modal.locator("tr", { hasText: "PII Detection" }); - await targetRow.getByTestId("use-control-button").click(); - - const createControlModal = mockedPage - .getByRole("dialog") - .filter({ hasText: "Create Control" }); - await expect(createControlModal).toBeVisible(); - - // Check control name is pre-filled with -copy suffix (sanitized) - const controlNameInput = createControlModal.getByPlaceholder("Enter control name"); - await expect(controlNameInput).toHaveValue("PII-Detection-copy"); - - // Check evaluator config is pre-filled (PII Detection uses regex with SSN pattern) - const patternInput = createControlModal.getByPlaceholder("Enter regex pattern (e.g., ^.*$)"); - await expect(patternInput).toHaveValue("\\b\\d{3}-\\d{2}-\\d{4}\\b"); - }); - - test("Footer 'Create new control' button opens add-new-control modal", async ({ mockedPage }) => { - const modal = await openControlStoreModal(mockedPage); - await modal.getByTestId("footer-new-control-button").click(); - - await expect( - mockedPage.getByText("Browse and add controls to your agent") + modal.getByText("Browse and add controls to your agent") ).toBeVisible(); }); -}); - -test.describe("Add New Control Modal", () => { - test("displays modal header and description", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); - await expect(modal.getByRole("heading", { name: "Control store" })).toBeVisible(); - await expect(modal.getByText("Browse and add controls to your agent")).toBeVisible(); - }); test("displays source selection sidebar", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); await expect(modal.getByRole("button", { name: "OOB standard" })).toBeVisible(); await expect(modal.getByRole("button", { name: "Custom" })).toBeVisible(); }); test("OOB standard is selected by default", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); await expect(modal.getByText("OOB standard")).toBeVisible(); }); test("displays evaluators table with available evaluators", async ({ mockedPage, }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); await expect(modal.getByRole("columnheader", { name: "Name" })).toBeVisible(); await expect(modal.getByRole("columnheader", { name: "Version" })).toBeVisible(); await expect(modal.getByRole("columnheader", { name: "Description" })).toBeVisible(); @@ -167,7 +49,7 @@ test.describe("Add New Control Modal", () => { }); test("can search for evaluators", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); const searchInput = modal.getByPlaceholder("Search or apply filter..."); await searchInput.fill("Regex"); @@ -176,7 +58,7 @@ test.describe("Add New Control Modal", () => { }); test("shows empty state when search has no results", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); const searchInput = modal.getByPlaceholder("Search or apply filter..."); await searchInput.fill("NonexistentEvaluator"); @@ -184,7 +66,7 @@ test.describe("Add New Control Modal", () => { }); test("shows empty state for Custom source", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); await modal.getByRole("button", { name: "Custom" }).click(); await expect(modal.getByText("No custom controls yet")).toBeVisible(); @@ -194,7 +76,7 @@ test.describe("Add New Control Modal", () => { }); test("Add button opens create control modal", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); const tableRow = modal.locator("tbody tr").first(); await tableRow.getByRole("button", { name: "Add" }).click(); @@ -202,15 +84,22 @@ test.describe("Add New Control Modal", () => { }); test("displays docs link", async ({ mockedPage }) => { - const modal = await openAddNewControlModal(mockedPage); + const modal = await openControlStoreModal(mockedPage); await expect(modal.getByText("Looking to add custom control?")).toBeVisible(); await expect(modal.getByText("Check our Docs ↗")).toBeVisible(); }); + + test("can close modal with X button", async ({ mockedPage }) => { + const modal = await openControlStoreModal(mockedPage); + await modal.getByTestId("close-control-store-modal-button").click(); + await expect( + mockedPage.getByText("Browse and add controls to your agent") + ).not.toBeVisible(); + }); }); test.describe("Control Store - Loading States", () => { - test("shows error state when controls fail to load", async ({ page }) => { - // Mock agent controls to return normally + test("shows error state when evaluators fail to load", async ({ page }) => { await page.route("**/api/v1/agents/*/controls", async (route) => { await route.fulfill({ status: 200, @@ -219,7 +108,6 @@ test.describe("Control Store - Loading States", () => { }); }); - // Mock agent to return normally await page.route("**/api/v1/agents/*", async (route) => { await route.fulfill({ status: 200, @@ -228,21 +116,18 @@ test.describe("Control Store - Loading States", () => { }); }); - // Mock controls list to fail - await page.route("**/api/v1/controls**", async (route) => { + await page.route("**/api/v1/evaluators", async (route) => { await route.fulfill({ status: 500, contentType: "application/json", - body: JSON.stringify({ error: "Failed to fetch controls" }), + body: JSON.stringify({ error: "Failed to fetch evaluators" }), }); }); await page.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); - // Open the control store modal await page.getByTestId("add-control-button").first().click(); - // Should show error state - await expect(page.getByText("Failed to load controls")).toBeVisible(); + await expect(page.getByText("Failed to load evaluators")).toBeVisible(); }); }); diff --git a/ui/tests/evaluators/helpers.ts b/ui/tests/evaluators/helpers.ts index 4c07321e..219a9df5 100644 --- a/ui/tests/evaluators/helpers.ts +++ b/ui/tests/evaluators/helpers.ts @@ -12,15 +12,8 @@ const AGENT_URL = "/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"; export async function openEvaluatorForm(page: Page, evaluatorName: string) { await page.goto(AGENT_URL); - // Open control store modal + // Open control store modal (add-new flow) await page.getByTestId("add-control-button").first().click(); - const controlStoreModal = page - .getByRole("dialog") - .filter({ hasText: "Browse existing controls or create a new one" }); - await expect(controlStoreModal).toBeVisible(); - - // Open the add-new-control modal via footer CTA - await controlStoreModal.getByTestId("footer-new-control-button").click(); const addNewModal = page .getByRole("dialog") .filter({ hasText: "Browse and add controls to your agent" }); @@ -33,4 +26,3 @@ export async function openEvaluatorForm(page: Page, evaluatorName: string) { // Wait for the create control modal await expect(page.getByRole("heading", { name: "Create Control" })).toBeVisible(); } - diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts index d58a1b96..8b6ed425 100644 --- a/ui/tests/fixtures.ts +++ b/ui/tests/fixtures.ts @@ -146,7 +146,10 @@ const controlSummariesList: (ControlSummary & { used_by_agent?: { agent_id: stri step_types: ["llm"], stages: ["post"], tags: ["pii", "compliance"], - used_by_agent: { agent_id: "agent-1", agent_name: "Customer Support Bot" }, + used_by_agent: { + agent_id: "9c15431d-c252-4c1b-80e0-d49ecda4f4b5", + agent_name: "Customer Support Bot", + }, }, { id: 2, @@ -157,7 +160,10 @@ const controlSummariesList: (ControlSummary & { used_by_agent?: { agent_id: stri step_types: ["tool"], stages: ["pre"], tags: ["security"], - used_by_agent: { agent_id: "agent-2", agent_name: "Data Analysis Agent" }, + used_by_agent: { + agent_id: "b286abfe-6363-4593-85e0-fa71478f1906", + agent_name: "Data Analysis Agent", + }, }, { id: 3, diff --git a/ui/tests/home.spec.ts b/ui/tests/home.spec.ts index 4028a7b2..4f061264 100644 --- a/ui/tests/home.spec.ts +++ b/ui/tests/home.spec.ts @@ -33,13 +33,9 @@ test.describe("Home Page - Agents Overview", () => { const searchInput = mockedPage.getByPlaceholder("Search agents..."); await searchInput.fill("Customer"); - // Wait for debounced search (300ms) and API response - await mockedPage.waitForResponse( - (response) => - response.url().includes("/api/v1/agents") && - response.url().includes("name=Customer") && - response.status() === 200 - ); + // Wait for debounced URL update (300ms) + await mockedPage.waitForTimeout(350); + await expect(mockedPage).toHaveURL(/.*\?search=Customer/); // Only the matching agent should be visible await expect(mockedPage.getByText("Customer Support Bot")).toBeVisible(); @@ -149,4 +145,3 @@ test.describe("Home Page - Agents Overview", () => { await expect(page.getByText("Failed to fetch agents. Please try again later.")).toBeVisible(); }); }); - diff --git a/ui/tests/search-input.spec.ts b/ui/tests/search-input.spec.ts index c6a01cc7..ddc08131 100644 --- a/ui/tests/search-input.spec.ts +++ b/ui/tests/search-input.spec.ts @@ -4,6 +4,8 @@ test.describe("SearchInput - Query Param Syncing", () => { test("syncs search value to URL query param", async ({ mockedPage }) => { await mockedPage.goto("/"); + await expect(mockedPage.getByRole("table")).toBeVisible(); + const searchInput = mockedPage.getByPlaceholder("Search agents..."); // Type in search input @@ -19,15 +21,7 @@ test.describe("SearchInput - Query Param Syncing", () => { test("reads search value from URL query param on page load", async ({ mockedPage }) => { // Navigate with query param await mockedPage.goto("/?search=Customer"); - - // Wait for API response with the search filter - await mockedPage.waitForResponse( - (response) => - response.url().includes("/api/v1/agents") && - response.url().includes("name=Customer") && - response.status() === 200 - ); - + // Wait for page to load await expect(mockedPage.getByRole("table")).toBeVisible(); @@ -41,7 +35,7 @@ test.describe("SearchInput - Query Param Syncing", () => { test("clear button removes query param from URL", async ({ mockedPage }) => { await mockedPage.goto("/?search=Customer"); - + // Wait for page to load await expect(mockedPage.getByRole("table")).toBeVisible(); @@ -70,6 +64,8 @@ test.describe("SearchInput - Query Param Syncing", () => { test("preserves search state on browser back/forward", async ({ mockedPage }) => { await mockedPage.goto("/"); + await expect(mockedPage.getByRole("table")).toBeVisible(); + // Type search const searchInput = mockedPage.getByPlaceholder("Search agents..."); await searchInput.fill("Customer"); @@ -84,7 +80,9 @@ test.describe("SearchInput - Query Param Syncing", () => { // Navigate away await mockedPage.getByText("Customer Support Bot").click(); - await expect(mockedPage).toHaveURL(/\/agents\/agent-1/); + await expect(mockedPage).toHaveURL( + /\/agents\/9c15431d-c252-4c1b-80e0-d49ecda4f4b5/ + ); // Go back await mockedPage.goBack(); @@ -106,8 +104,10 @@ test.describe("SearchInput - Query Param Syncing", () => { test.describe("SearchInput - Agent Detail Page", () => { test("syncs search value to URL query param (q)", async ({ mockedPage }) => { - await mockedPage.goto("/agents/agent-1"); + await mockedPage.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5"); + await expect(mockedPage.getByRole("table")).toBeVisible(); + const searchInput = mockedPage.getByPlaceholder("Search controls..."); await searchInput.fill("PII"); @@ -119,7 +119,7 @@ test.describe("SearchInput - Agent Detail Page", () => { }); test("reads search value from URL on page load", async ({ mockedPage }) => { - await mockedPage.goto("/agents/agent-1?q=PII"); + await mockedPage.goto("/agents/9c15431d-c252-4c1b-80e0-d49ecda4f4b5?q=PII"); // Wait for page to load await expect(mockedPage.getByRole("table")).toBeVisible();