diff --git a/AGENTS.md b/AGENTS.md index 144b059..c9c30c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,9 @@ Reference specific personas when requesting work: - In the matrix **build** job, `astral-sh/setup-uv` sets `cache-suffix: py-${{ matrix.python-version }}` so parallel Python versions do not race on the same GitHub Actions cache reservation. - `@singleton` (from `supervaizer.common`) replaces the decorated class name with a function at import time; modules that annotate with that class in unions (e.g. `StorageManager | None` in `storage.py`) need `from __future__ import annotations` or class-body evaluation raises `TypeError`. - `UTC` lives on the `datetime` module (`from datetime import UTC`), not on `datetime.datetime`; use `datetime.now(UTC)`, not `datetime.now(datetime.UTC)` (the latter raises `AttributeError` at runtime). +- `just ship` bumps version in CI after merge to `main`, not in the ship PR itself; the publish job waits on GitHub Environment `pypi` approval; bump lands as `chore(release): vX.Y.Z`. After that, `just ship-reconcile` merges `main` back into `develop`. +- Do not re-run a failed `publish-pypi` workflow after the bump commit is already on `main` — it would bump again. Pushing other commits to `main` also retriggers publish with the default minor bump. +- hatchling ≥1.32 emits Metadata-Version 2.5; `pypa/gh-action-pypi-publish` must be ≥v1.14.2, pinned to the peeled tag commit SHA (not the annotated-tag object SHA). ## Security and Supply-Chain Rules diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e19456f..3ec28d8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,9 +12,30 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -### Fixed +### Added -- **PyPI publish (Metadata 2.5)** — Pin `pypa/gh-action-pypi-publish` to v1.14.2 so hatchling ≥1.32 wheels upload. Skip version bump unless the triggering commit has `[MAJOR]`/`[MINOR]`/`[PATCH]`. +- **Declared action scope and mutability (#92)** — `V2AgentCapabilities.actions` is now `list[V2ActionDefinition]` instead of an opaque `list[str]`. Each action declares `id`, `mutating` (does invoking it change agent-side state?), and `scope` (`workspace` / `mission` / `job`), so a consumer can authorize and group actions without pattern-matching the identifier. `id` is caller-supplied at invocation time, so deriving an authorization decision from it puts that decision in the caller's hands. + + Plain strings are still accepted everywhere a list of actions is taken — in `build_v2_agent_registration(actions=...)` and when validating a registration payload — and coerce to `mutating=True, scope="job"`, the fail-closed reading that matches today's behaviour. Only the serialized form changes: `capabilities.actions` now emits objects. Consumers must accept both, because agents pinned to older Supervaizer releases keep sending bare strings. No protocol version discriminates the two shapes: `versions.a2ui_version` and `versions.a2a_version` cover the surface catalog and the A2A protocol, not the registration payload. + + The builder derives the metadata from the definition each action comes from: resource actions take `V2ResourceDefinition.scope`, dataset queries are `mutating=False`, `V2JobSetupPolicy.preview_action` is `mutating=False`, and workspace binding actions are `workspace`-scoped. Resource operation ids are freeform, so operations stay `mutating=True` unless the agent declares otherwise. + + Metadata precedence is explicit > derived > bare: an explicit `V2ActionDefinition` in `actions=` overrides the derived metadata — that is how a job-scoped `resource.invoice.reconcile` on a workspace-scoped resource is declared — while a bare id string declares nothing and defers to the definition it was derived from. Precedence is about metadata, not position: an id declared both ways within `actions=` keeps the explicit metadata regardless of which form comes first, and the earlier mention still fixes the order. + +- **`V2DatasetDefinition.scope`** — Mirrors `V2ResourceDefinition.scope` (`workspace` / `mission` / `job`, defaulting to `workspace`) so dataset query actions carry a declared scope rather than an assumed one. + +- **`V2AwaitingState.reopenable`** — Declares whether an already-answered awaiting step may be reopened and resubmitted with edited values. Defaults to `False`; previously consumers inferred this from a substring of `surface`. + +### Tests + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 698 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 67s | ## [1.5.0] - 2026-08-28 @@ -28,6 +49,21 @@ All notable changes to this project will be documented in this file. - **Regenerated model reference and OpenAPI** — `docs/model_reference/` and `docs/api/openapi.json` were rebuilt from the current models, picking up the job setup contract along with accumulated drift since `0.20.1`. `V2JobSetupPolicy` is now declared before `V2JobPolicy` so the public reference renders the real type instead of a `ForwardRef`. +### Fixed + +- **PyPI publish (Metadata 2.5)** — Pin `pypa/gh-action-pypi-publish` to v1.14.2 so hatchling ≥1.32 wheels upload. Skip version bump unless the triggering commit has `[MAJOR]`/`[MINOR]`/`[PATCH]`. + +### Tests + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 691 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 65s | + ## [1.4.0] - 2026-07-07 ### Added diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 435428b..4c158f4 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Supervaize API", - "description": "API version: v1 Controller version: 1.3.1\n\nAPI for controlling and managing Supervaize agents. \n\nMore information at [https://doc.supervaize.com](https://doc.supervaize.com)\n\n## Authentication\n\nSome endpoints require API key authentication. Protected endpoints expect the API key in the X-API-Key header.\n\n[Swagger](/docs)\n[Redoc](/redoc)\n[OpenAPI](/openapi.json)\n", + "description": "API version: v1 Controller version: 1.5.0\n\nAPI for controlling and managing Supervaize agents. \n\nMore information at [https://doc.supervaize.com](https://doc.supervaize.com)\n\n## Authentication\n\nSome endpoints require API key authentication. Protected endpoints expect the API key in the X-API-Key header.\n\n[Swagger](/docs)\n[Redoc](/redoc)\n[OpenAPI](/openapi.json)\n", "termsOfService": "https://supervaize.com/terms/", "contact": { "name": "Support Team", @@ -4187,7 +4187,7 @@ "type": "string", "format": "date-time", "title": "Timestamp", - "default": "2026-08-26T17:21:02.106370" + "default": "2026-08-29T19:33:56.088920" }, "status_code": { "type": "integer", diff --git a/docs/model_reference/model_core.md b/docs/model_reference/model_core.md index fce37b8..fe54a16 100644 --- a/docs/model_reference/model_core.md +++ b/docs/model_reference/model_core.md @@ -1,6 +1,6 @@ # Model Reference Core -**Version:** 1.3.1 +**Version:** 1.5.0 ### `account.Account` @@ -443,4 +443,4 @@ public_url: full url (including scheme and port) to use for outbound connections ``` -*Uploaded on 2026-08-26 17:21:01* +*Uploaded on 2026-08-29 19:33:55* diff --git a/docs/model_reference/model_extra.md b/docs/model_reference/model_extra.md index 54e034c..6810c0d 100644 --- a/docs/model_reference/model_extra.md +++ b/docs/model_reference/model_extra.md @@ -1,6 +1,6 @@ # Model Reference extra -**Version:** 1.3.1 +**Version:** 1.5.0 ### `common.SvBaseModel` @@ -226,6 +226,25 @@ _No additional fields beyond parent class._ | `dashboards` | `list[contracts.V2DashboardDefinition]` | — | | | `workspace_binding` | `V2WorkspaceBindingDefinition` | `None` | | +### `contracts.V2ActionDefinition` + +**Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) + +Declared metadata for one invokable agent action. + +Consumers authorize, group and filter actions from these fields. They must +never be inferred from the identifier string: `id` is caller-supplied on +invocation, so pattern-matching it turns an authorization decision into +something the caller controls. + +#### Model Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `str` | **required** | Action identifier used to invoke the action. | +| `mutating` | `bool` | True | Whether invoking the action changes agent-side state. Defaults to True so an undeclared action requires write permission. | +| `scope` | `Literal['workspace', 'mission', 'job']` | 'job' | Context the action operates within. | + ### `contracts.V2ActionRequest` **Inherits from:** [`contracts.ContractModel`](#contractscontractmodel) @@ -719,7 +738,7 @@ A2UI-shaped resource import surface consumed by Studio. | Field | Type | Default | Description | |---|---|---|---| | `surfaces` | `list[str]` | — | | -| `actions` | `list[str]` | — | | +| `actions` | `list[contracts.V2ActionDefinition]` | — | | | `case_lanes` | `list[contracts.V2CaseLaneDefinition]` | — | | | `artifact_types` | `list[contracts.V2ArtifactTypeDefinition]` | — | | @@ -786,6 +805,7 @@ A2UI-shaped resource import surface consumed by Studio. | `surface` | `str` | **required** | | | `action` | `str` | **required** | | | `fields` | `list[contracts.V2AwaitingFieldDefinition]` | — | | +| `reopenable` | `bool` | False | Whether an already-answered step may be reopened and resubmitted with edited values. | ### `contracts.V2CaseLaneDefinition` @@ -904,6 +924,7 @@ A2UI-shaped resource import surface consumed by Studio. | `id` | `str` | **required** | | | `label` | `str` | **required** | | | `auto_surface` | `bool` | False | | +| `scope` | `Literal['workspace', 'mission', 'job']` | 'workspace' | | | `display` | `V2ResourceDisplayDefinition` | `None` | | ### `contracts.V2Effect` @@ -1562,7 +1583,7 @@ Standard error response model | `error` | `str` | **required** | | | `error_type` | `` | **required** | | | `detail` | `str` | `None` | | -| `timestamp` | `datetime` | datetime.datetime(2026, 8, 26, 17, 21, 1, 586513) | | +| `timestamp` | `datetime` | datetime.datetime(2026, 8, 29, 19, 33, 55, 605372) | | | `status_code` | `int` | **required** | | ### `routes.RegistrationRefreshRequest` @@ -1630,4 +1651,4 @@ A base class for creating Pydantic models. | `jti` | `str` | `None` | | -*Uploaded on 2026-08-26 17:21:01* +*Uploaded on 2026-08-29 19:33:55* diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 6177720..0acd7da 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -138,6 +138,7 @@ "V2A2UIResourceImportDocument", ), "V2A2UISubmitDefinition": ("supervaizer.contracts", "V2A2UISubmitDefinition"), + "V2ActionDefinition": ("supervaizer.contracts", "V2ActionDefinition"), "V2AgentCapabilities": ("supervaizer.contracts", "V2AgentCapabilities"), "V2AgentIdentity": ("supervaizer.contracts", "V2AgentIdentity"), "V2AgentMethod": ("supervaizer.contracts", "V2AgentMethod"), diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 81910f5..720fe34 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -39,6 +39,7 @@ from supervaizer.common import ApiSuccess, SvBaseModel, log from supervaizer.contracts import ( SupervaizerV2AgentRegistrationContract, + V2ActionDefinition, V2ActionRequest, V2AgentMethod, V2AgentMethods, @@ -849,13 +850,16 @@ def _validate_supervaizer_v2_identity(self) -> None: def _apply_v2_method_capabilities(self) -> None: if self.supervaizer_v2_registration is None or self.v2_methods is None: return - actions = [ - *self.supervaizer_v2_registration.capabilities.actions, - *self.v2_methods.action_ids, + declared = self.supervaizer_v2_registration.capabilities.actions + known_ids = {action.id for action in declared} + self.supervaizer_v2_registration.capabilities.actions = [ + *declared, + *( + V2ActionDefinition(id=action_id, mutating=True, scope="job") + for action_id in dict.fromkeys(self.v2_methods.action_ids) + if action_id not in known_ids + ), ] - self.supervaizer_v2_registration.capabilities.actions = list( - dict.fromkeys(actions) - ) @property def slug(self) -> str: diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 545ea3b..545614a 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -308,12 +308,50 @@ class V2ArtifactTypeDefinition(ContractModel): renderer_surface: str | None = None +class V2ActionDefinition(ContractModel): + """Declared metadata for one invokable agent action. + + Consumers authorize, group and filter actions from these fields. They must + never be inferred from the identifier string: `id` is caller-supplied on + invocation, so pattern-matching it turns an authorization decision into + something the caller controls. + """ + + id: str = Field(description="Action identifier used to invoke the action.") + mutating: bool = Field( + default=True, + description=( + "Whether invoking the action changes agent-side state. " + "Defaults to True so an undeclared action requires write permission." + ), + ) + scope: Literal["workspace", "mission", "job"] = Field( + default="job", + description="Context the action operates within.", + ) + + @field_validator("id") + @classmethod + def validate_id_is_named(cls, value: str) -> str: + if not value.strip(): + raise ValueError("action definitions must have a non-empty id") + return value + + class V2AgentCapabilities(ContractModel): surfaces: list[str] = Field(default_factory=list) - actions: list[str] = Field(default_factory=list) + actions: list[V2ActionDefinition] = Field(default_factory=list) case_lanes: list[V2CaseLaneDefinition] = Field(default_factory=list) artifact_types: list[V2ArtifactTypeDefinition] = Field(default_factory=list) + @field_validator("actions", mode="before") + @classmethod + def coerce_action_strings(cls, value: Any) -> Any: + """Accept the legacy `list[str]` form; a bare id keeps the safe defaults.""" + if not isinstance(value, list): + return value + return [{"id": item} if isinstance(item, str) else item for item in value] + class V2AgentMethod(ContractModel): method: str @@ -485,6 +523,7 @@ class V2DatasetDefinition(ContractModel): id: str label: str auto_surface: bool = False + scope: Literal["workspace", "mission", "job"] = "workspace" display: V2ResourceDisplayDefinition | None = None @@ -592,7 +631,7 @@ def build_v2_agent_registration( controller_url: str, a2ui_catalog_version: str, surfaces: Iterable[str] = (), - actions: Iterable[str] = (), + actions: Iterable[str | V2ActionDefinition | dict[str, Any]] = (), resources: Iterable[V2ResourceDefinition | dict[str, Any]] = (), datasets: Iterable[V2DatasetDefinition | dict[str, Any]] = (), dashboards: Iterable[V2DashboardDefinition | dict[str, Any]] = (), @@ -622,15 +661,17 @@ def build_v2_agent_registration( *_dashboard_surface_ids(dashboard_definitions), *_workspace_binding_surface_ids(workspace_binding_definition), ]) - capability_actions = _unique_strings([ - *actions, - *_resource_action_ids(resource_definitions), - *_dataset_action_ids(dataset_definitions), - *_job_sync_actions(job_policy_definition), - *_job_setup_actions(job_policy_definition), - *_workspace_binding_action_ids(workspace_binding_definition), - *_agent_method_action_ids(agent_method_definitions), - ]) + capability_actions = _merge_actions( + declared=actions, + derived=[ + *_resource_actions(resource_definitions), + *_dataset_actions(dataset_definitions), + *_job_sync_actions(job_policy_definition), + *_job_setup_actions(job_policy_definition), + *_workspace_binding_actions(workspace_binding_definition), + *_agent_method_actions(agent_method_definitions), + ], + ) return SupervaizerV2AgentRegistrationContract( agent=V2AgentIdentity( @@ -741,51 +782,108 @@ def _dashboard_surface_ids(dashboards: Iterable[V2DashboardDefinition]) -> list[ return [dashboard.surface for dashboard in dashboards] -def _resource_action_ids(resources: Iterable[V2ResourceDefinition]) -> list[str]: +def _resource_actions( + resources: Iterable[V2ResourceDefinition], +) -> list[V2ActionDefinition]: + # Resource operations are freeform ids, so mutability cannot be derived from + # them: declare the action explicitly in `actions` to mark one read-only. return [ - f"resource.{resource.id}.{operation}" + V2ActionDefinition( + id=f"resource.{resource.id}.{operation}", + mutating=True, + scope=resource.scope, + ) for resource in resources for operation in resource.operations ] -def _dataset_action_ids(datasets: Iterable[V2DatasetDefinition]) -> list[str]: - return [f"dataset.{dataset.id}.query" for dataset in datasets] +def _dataset_actions( + datasets: Iterable[V2DatasetDefinition], +) -> list[V2ActionDefinition]: + return [ + V2ActionDefinition( + id=f"dataset.{dataset.id}.query", + mutating=False, + scope=dataset.scope, + ) + for dataset in datasets + ] -def _job_sync_actions(job_policy: V2JobPolicy) -> list[str]: +def _job_sync_actions(job_policy: V2JobPolicy) -> list[V2ActionDefinition]: if job_policy.sync is None: return [] - return [job_policy.sync.action] + return [V2ActionDefinition(id=job_policy.sync.action, mutating=True, scope="job")] -def _job_setup_actions(job_policy: V2JobPolicy) -> list[str]: - if job_policy.setup is None: +def _job_setup_actions(job_policy: V2JobPolicy) -> list[V2ActionDefinition]: + setup = job_policy.setup + if setup is None: return [] return [ - job_policy.setup.preview_action, - job_policy.setup.start_action, - job_policy.setup.submit_action, + V2ActionDefinition(id=setup.preview_action, mutating=False, scope="job"), + V2ActionDefinition(id=setup.start_action, mutating=True, scope="job"), + V2ActionDefinition(id=setup.submit_action, mutating=True, scope="job"), ] -def _agent_method_action_ids(agent_methods: V2AgentMethods | None) -> list[str]: +def _agent_method_actions( + agent_methods: V2AgentMethods | None, +) -> list[V2ActionDefinition]: if agent_methods is None: return [] - return agent_methods.action_ids + return [ + V2ActionDefinition(id=action_id, mutating=True, scope="job") + for action_id in agent_methods.action_ids + ] -def _workspace_binding_action_ids( +def _workspace_binding_actions( workspace_binding: V2WorkspaceBindingDefinition | None, -) -> list[str]: +) -> list[V2ActionDefinition]: if workspace_binding is None: return [] - action_ids: list[str] = [] - if workspace_binding.existing is not None: - action_ids.append(workspace_binding.existing.action) - if workspace_binding.create is not None: - action_ids.append(workspace_binding.create.action) - return action_ids + bindings = [workspace_binding.existing, workspace_binding.create] + return [ + V2ActionDefinition(id=binding.action, mutating=True, scope="workspace") + for binding in bindings + if binding is not None + ] + + +def _merge_actions( + declared: Iterable[str | V2ActionDefinition | dict[str, Any]], + derived: Iterable[V2ActionDefinition], +) -> list[V2ActionDefinition]: + """Merge explicitly declared actions with the ones derived from definitions. + + First mention fixes the order. A bare id string in ``actions`` declares no + metadata, so it defers to the definition it was derived from; an explicit + ``V2ActionDefinition`` overrides the derived one. Precedence is about + metadata, not position: an id declared both ways within ``actions`` keeps the + explicit metadata wherever the two appear, and the earlier mention still + fixes the order. + """ + resolved: dict[str, V2ActionDefinition] = {} + bare_ids: set[str] = set() + for entry in declared: + is_bare = isinstance(entry, str) + action = V2ActionDefinition.model_validate({"id": entry} if is_bare else entry) + if action.id not in resolved: + resolved[action.id] = action + if is_bare: + bare_ids.add(action.id) + elif not is_bare and action.id in bare_ids: + resolved[action.id] = action + bare_ids.discard(action.id) + for action in derived: + if action.id not in resolved: + resolved[action.id] = action + elif action.id in bare_ids: + resolved[action.id] = action + bare_ids.discard(action.id) + return list(resolved.values()) def _workspace_binding_surface_ids( @@ -948,6 +1046,13 @@ class V2AwaitingState(ContractModel): surface: str action: str fields: list[V2AwaitingFieldDefinition] = Field(default_factory=list) + reopenable: bool = Field( + default=False, + description=( + "Whether an already-answered step may be reopened and resubmitted " + "with edited values." + ), + ) class V2StepSnapshot(ContractModel): diff --git a/tests/test_agent.py b/tests/test_agent.py index 318752e..d5b2cbe 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1273,7 +1273,9 @@ def test_agent_v2_methods_are_added_to_v2_capabilities() -> None: ) assert agent.supervaizer_v2_registration is not None - assert agent.supervaizer_v2_registration.capabilities.actions == [ + assert [ + action.id for action in agent.supervaizer_v2_registration.capabilities.actions + ] == [ "job.sync", AGENT_REFRESH_ACTION, ] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 1a4fd53..d55d79c 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -26,8 +26,10 @@ ServerRegistrationContract, SupervaizerV2AgentRegistrationContract, V2A2UIResourceImportDocument, + V2ActionDefinition, V2ActionRequest, V2ActionResult, + V2AgentCapabilities, V2AgentMethod, V2AgentMethods, V2AwaitingState, @@ -43,6 +45,7 @@ V2JobSyncPolicy, V2JobSyncResult, V2ReplaySafetyMetadata, + V2ResourceDefinition, V2ResourceFieldDefinition, V2SurfaceRequest, V2SurfaceResult, @@ -270,11 +273,12 @@ def test_v2_agent_interviewer_registration_fixture() -> None: assert ( "mission.agent.surface.scenario_builder" in registration.capabilities.surfaces ) - assert AGENT_REFRESH_ACTION in registration.capabilities.actions - assert "resource.campaign_contacts.create" in registration.capabilities.actions - assert "resource.campaign_contacts.delete" in registration.capabilities.actions - assert "resource.contacts.import" in registration.capabilities.actions - assert "resource.scenarios.update" in registration.capabilities.actions + action_ids = [action.id for action in registration.capabilities.actions] + assert AGENT_REFRESH_ACTION in action_ids + assert "resource.campaign_contacts.create" in action_ids + assert "resource.campaign_contacts.delete" in action_ids + assert "resource.contacts.import" in action_ids + assert "resource.scenarios.update" in action_ids assert any( lane.id == "work" and lane.default for lane in registration.capabilities.case_lanes @@ -423,15 +427,18 @@ def test_build_v2_agent_registration_derives_capabilities() -> None: "mission.analytics", "workspace_binding.create", ] - assert registration.capabilities.actions == [ - "job.start", - "step.awaiting.submit", - "resource.contacts.list", - "resource.contacts.create", - "dataset.campaign_progress.query", - "job.sync", - "workspace_binding.options", - "workspace_binding.create", + assert [ + (action.id, action.mutating, action.scope) + for action in registration.capabilities.actions + ] == [ + ("job.start", True, "job"), + ("step.awaiting.submit", True, "job"), + ("resource.contacts.list", True, "workspace"), + ("resource.contacts.create", True, "workspace"), + ("dataset.campaign_progress.query", False, "workspace"), + ("job.sync", True, "job"), + ("workspace_binding.options", True, "workspace"), + ("workspace_binding.create", True, "workspace"), ] assert registration.workspace_binding is not None assert registration.workspace_binding.existing is not None @@ -479,13 +486,136 @@ def test_build_v2_agent_registration_derives_agent_method_actions() -> None: ), ) - assert registration.capabilities.actions == [ + assert [action.id for action in registration.capabilities.actions] == [ AGENT_REFRESH_ACTION, "agent.custom.reindex", "agent.custom.dry-run", ] +def test_v2_capabilities_coerce_bare_action_strings() -> None: + """A legacy `list[str]` keeps today's meaning: mutating and job-scoped.""" + capabilities = V2AgentCapabilities.model_validate({"actions": ["job.start"]}) + + assert capabilities.actions == [ + V2ActionDefinition(id="job.start", mutating=True, scope="job") + ] + assert capabilities.model_dump()["actions"] == [ + {"id": "job.start", "mutating": True, "scope": "job"} + ] + + +def test_v2_capabilities_reject_blank_action_id() -> None: + with pytest.raises(ValidationError, match="non-empty id"): + V2AgentCapabilities.model_validate({"actions": [" "]}) + + +def test_v2_declared_action_overrides_derived_metadata() -> None: + """An explicit definition wins over the metadata derived from its parent.""" + registration = build_v2_agent_registration( + agent_id="agent-1", + agent_slug="agent", + display_name="Agent", + agent_card_url="/card.json", + controller_url="/a2a", + a2ui_catalog_version="supervaizer-v2-local.0", + actions=[ + V2ActionDefinition( + id="resource.invoices.reconcile", mutating=True, scope="job" + ) + ], + resources=[ + V2ResourceDefinition( + id="invoices", + label="Invoices", + scope="workspace", + operations=["reconcile"], + ) + ], + ) + + reconcile = next( + action + for action in registration.capabilities.actions + if action.id == "resource.invoices.reconcile" + ) + assert reconcile.scope == "job" + assert len(registration.capabilities.actions) == 1 + + +@pytest.mark.parametrize("bare_first", [False, True]) +def test_v2_mixed_action_forms_keep_explicit_metadata(bare_first: bool) -> None: + """An id declared both ways keeps the explicit metadata, in either order.""" + explicit = V2ActionDefinition( + id="resource.invoices.reconcile", mutating=False, scope="job" + ) + declared: list[str | V2ActionDefinition] = ( + ["resource.invoices.reconcile", explicit] + if bare_first + else [explicit, "resource.invoices.reconcile"] + ) + registration = build_v2_agent_registration( + agent_id="agent-1", + agent_slug="agent", + display_name="Agent", + agent_card_url="/card.json", + controller_url="/a2a", + a2ui_catalog_version="supervaizer-v2-local.0", + actions=declared, + resources=[ + V2ResourceDefinition( + id="invoices", + label="Invoices", + scope="workspace", + operations=["reconcile"], + ) + ], + ) + + assert registration.capabilities.actions == [explicit] + + +def test_v2_bare_action_string_defers_to_derived_metadata() -> None: + """A bare id declares no metadata, so the setup policy's derivation wins.""" + registration = build_v2_agent_registration( + agent_id="agent-1", + agent_slug="agent", + display_name="Agent", + agent_card_url="/card.json", + controller_url="/a2a", + a2ui_catalog_version="supervaizer-v2-local.0", + actions=["job.start.preview", "job.start"], + job_policy={"setup": {}}, + ) + + assert [ + (action.id, action.mutating) for action in registration.capabilities.actions + ] == [ + ("job.start.preview", False), + ("job.start", True), + ("step.awaiting.submit", True), + ] + + +def test_v2_awaiting_state_is_not_reopenable_by_default() -> None: + awaiting = V2AwaitingState.model_validate({ + "reason": "Review campaign setup", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit", + }) + + assert awaiting.reopenable is False + assert ( + V2AwaitingState.model_validate({ + "reason": "Review campaign setup", + "surface": "case.step.awaiting", + "action": "step.awaiting.submit", + "reopenable": True, + }).reopenable + is True + ) + + def test_v2_workspace_binding_required_requires_mode() -> None: with pytest.raises(ValidationError, match="at least one mode"): V2WorkspaceBindingDefinition(required=True) @@ -575,11 +705,17 @@ def test_v2_job_setup_policy_round_trips_through_registration_builder() -> None: "case", "step", ] - assert set(round_trip.capabilities.actions) >= { - "job.start.preview", - "job.start", - "step.awaiting.submit", + mutating_by_id = { + action.id: action.mutating for action in round_trip.capabilities.actions } + assert ( + mutating_by_id.items() + >= { + "job.start.preview": False, + "job.start": True, + "step.awaiting.submit": True, + }.items() + ) def test_v2_job_setup_policy_declares_no_scopes_by_default() -> None: diff --git a/tests/test_server.py b/tests/test_server.py index 4a0f4d8..75302bb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1113,9 +1113,10 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: capabilities = card_response.json()["supervaizer"]["v2"]["capabilities"] assert "case.step.awaiting" in capabilities["surfaces"] assert "mission.agent.resource.hello_messages" in capabilities["surfaces"] - assert "job.sync" in capabilities["actions"] - assert "step.awaiting.submit" in capabilities["actions"] - assert "resource.hello_messages.list" in capabilities["actions"] + action_ids = [action["id"] for action in capabilities["actions"]] + assert "job.sync" in action_ids + assert "step.awaiting.submit" in action_ids + assert "resource.hello_messages.list" in action_ids surface_response = client.post( "/a2a",