diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index d253fb1199d..bd34914909c 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -66,14 +66,30 @@ class AgentIdPath(BaseModel): class AgentAppCreatePayload(BaseModel): name: str = Field(..., min_length=1, description="Agent name") description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400) - role: str = Field(default="", description="Agent role", max_length=255) + role: str = Field(..., min_length=1, description="Agent role", max_length=255) icon_type: IconType | None = Field(default=None, description="Icon type") icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") + @field_validator("role") + @classmethod + def validate_role(cls, value: str) -> str: + role = value.strip() + if not role: + raise ValueError("Agent role is required.") + return role + class AgentAppUpdatePayload(UpdateAppPayload): - role: str | None = Field(default=None, description="Agent role", max_length=255) + role: str = Field(..., min_length=1, description="Agent role", max_length=255) + + @field_validator("role") + @classmethod + def validate_role(cls, value: str) -> str: + role = value.strip() + if not role: + raise ValueError("Agent role is required.") + return role class AgentAppPublishedReferenceResponse(BaseModel): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index fd278d42372..bcbe23d4c3e 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -11376,7 +11376,7 @@ Default namespace | icon_background | string | Icon background color | No | | icon_type | [IconType](#icontype) | Icon type | No | | name | string | Agent name | Yes | -| role | string | Agent role | No | +| role | string | Agent role | Yes | #### AgentAppFeaturesPayload @@ -11458,7 +11458,7 @@ default (the config form sends the full desired feature state on save). | icon_type | [IconType](#icontype) | Icon type | No | | max_active_requests | integer | Maximum active requests | No | | name | string | App name | Yes | -| role | string | Agent role | No | +| role | string | Agent role | Yes | | use_icon_as_answer_icon | boolean | Use icon as answer icon | No | #### AgentAverageResponseTimeStatisticResponse diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 499fdf54631..e62985d64dc 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -274,7 +274,13 @@ def test_agent_app_list_and_create_use_agent_route( with app.test_request_context( "/console/api/agent", - json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}, + json={ + "name": "Iris", + "description": "Agent app", + "role": "Coordinator", + "icon_type": "emoji", + "icon": "robot", + }, ): created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) @@ -287,6 +293,23 @@ def test_agent_app_list_and_create_use_agent_route( create_call = cast(dict[str, object], captured["create"]) create_params = cast(Any, create_call["params"]) assert create_params.mode == "agent" + assert create_params.agent_role == "Coordinator" + + +def test_agent_app_create_requires_role(app: Flask, account_id: str) -> None: + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}, + ): + with pytest.raises(ValueError, match="Field required"): + unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) + + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "role": " ", "icon_type": "emoji", "icon": "robot"}, + ): + with pytest.raises(ValueError, match="Agent role is required"): + unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) def test_agent_app_detail_update_delete_resolve_app_from_agent_id( @@ -335,7 +358,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( with app.test_request_context( "/console/api/agent/00000000-0000-0000-0000-000000000001", - json={"name": "Renamed", "description": "", "icon_type": "emoji", "icon": "R"}, + json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"}, ): updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) @@ -347,6 +370,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert "bound_agent_id" not in updated update_call = cast(dict[str, object], captured["update"]) assert update_call["app"] is app_model + assert cast(dict[str, object], update_call["args"])["role"] == "Reviewer" deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), "tenant-1", agent_id) assert (deleted, status) == ("", 204) @@ -399,6 +423,45 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( } +def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id) + captured: dict[str, object] = {} + + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_agent_app_model", + lambda _self, **kwargs: app_model, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_app_backing_agent", + lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="", active_config_snapshot_id=None), + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + class FakeAppService: + def get_app(self, app_obj: object) -> object: + return app_obj + + def update_app(self, app_obj: object, args: dict[str, object]) -> object: + captured["update"] = {"app": app_obj, "args": args} + return _app_detail_obj(id="app-1", name=args["name"], bound_agent_id=agent_id) + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001", + json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"}, + ): + with pytest.raises(ValueError, match="String should have at least 1 character"): + unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + + def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 9daae340350..46cb0545405 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -18,7 +18,7 @@ export type AgentAppCreatePayload = { icon_background?: string | null icon_type?: IconType | null name: string - role?: string + role: string } export type AppDetailWithSite = { @@ -67,7 +67,7 @@ export type AgentAppUpdatePayload = { icon_type?: IconType | null max_active_requests?: number | null name: string - role?: string | null + role: string use_icon_as_answer_icon?: boolean | null } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 07e753e0c9b..6811b8a80bb 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -92,7 +92,7 @@ export const zAgentAppCreatePayload = z.object({ icon_background: z.string().nullish(), icon_type: zIconType.nullish(), name: z.string().min(1), - role: z.string().max(255).optional().default(''), + role: z.string().min(1).max(255), }) /** @@ -105,7 +105,7 @@ export const zAgentAppUpdatePayload = z.object({ icon_type: zIconType.nullish(), max_active_requests: z.int().nullish(), name: z.string().min(1), - role: z.string().max(255).nullish(), + role: z.string().min(1).max(255), use_icon_as_answer_icon: z.boolean().nullish(), })