From 77fe4a1f2b7fab7d8391e0d98200ebec78b4ea20 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 3 Apr 2026 04:43:32 -0400 Subject: [PATCH 1/5] docs(runtime): clarify unsupported push notification surface (#391) --- docs/guide.md | 4 +++- src/opencode_a2a/server/agent_card.py | 5 +++-- tests/server/test_app_behaviors.py | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 07e3e38..ce86139 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -246,6 +246,7 @@ Current behavior: - `all_jsonrpc_methods` is the runtime truth for the current deployment. - The current SDK-owned core JSON-RPC surface includes `agent/getAuthenticatedExtendedCard` and `tasks/pushNotificationConfig/*`. - The current SDK-owned REST surface also includes `GET /v1/tasks` and the task push notification config routes. +- Push notification config routes/methods are currently exposed only because they are part of the SDK-owned core surface. This runtime does not configure a push config store or push sender, so push notification operations currently return `501` / unsupported. When `A2A_ENABLE_SESSION_SHELL=false`, `opencode.sessions.shell` is omitted from `all_jsonrpc_methods` and exposed only through `extensions.conditionally_available_methods`. @@ -286,7 +287,7 @@ Current compatibility matrix: | Transport payloads and enums | Supported | Partial | Request/response payloads, enums, and schema details still follow the SDK-owned `0.3` baseline. | | Error model | Supported | Partial | `0.3` keeps legacy `error.data={...}` / flat REST payloads; `1.0` uses protocol-aware JSON-RPC details and AIP-193-style REST errors. | | Pagination and list semantics | Supported | Partial | Cursor/list behavior is stable, but the declared shape still follows the `0.3` SDK baseline. | -| Push notification surfaces | Supported | Partial | Core task push-notification routes are available, but no extra `1.0`-specific compatibility layer is declared yet. | +| Push notification surfaces | Unsupported | Unsupported | SDK-owned task push-notification routes are still exposed, but this runtime does not enable push sender/config-store support and currently returns `501` / unsupported. | | Signatures and authenticated data | Supported | Partial | Security schemes and authenticated extended card discovery follow the shipped SDK schema rather than a dedicated `1.0` compatibility layer. | ## Compatibility Profile @@ -564,6 +565,7 @@ This service exposes OpenCode session lifecycle inspection, list/message-history - Privacy guard: when `A2A_LOG_PAYLOADS=true`, request/response bodies are still suppressed for `method=opencode.sessions.*` - Endpoint discovery: prefer `additional_interfaces[]` with `transport=jsonrpc` from Agent Card - The runtime still delegates SDK-owned JSON-RPC methods such as `agent/getAuthenticatedExtendedCard` and `tasks/pushNotificationConfig/*` to the base A2A implementation; they are not OpenCode-specific extensions. +- Push notification config methods remain effectively unsupported in the current runtime because no push config store or push sender is configured. - Notification behavior: for `opencode.sessions.*`, requests without `id` return HTTP `204 No Content` - Result format: - `opencode.sessions.status` => provider-private status summaries in `result.items` diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index 79ecca4..a6647c4 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -116,8 +116,9 @@ def _build_agent_card_description( "Supports HTTP+JSON and JSON-RPC transports, streaming-first A2A messaging " "(message/send, message/stream), authenticated extended Agent Card " "(agent/getAuthenticatedExtendedCard), task APIs (tasks/get, tasks/cancel, " - "tasks/resubscribe, push notification config methods; REST mappings " - "include GET /v1/tasks and GET /v1/tasks/{id}:subscribe), shared " + "tasks/resubscribe; SDK-owned push notification config surfaces remain " + "exposed but currently return unsupported; REST mappings include GET " + "/v1/tasks and GET /v1/tasks/{id}:subscribe), shared " "session-binding/model-selection/streaming contracts, provider-private " "OpenCode session/provider/model/workspace-control/interrupt recovery " "extensions, and shared interrupt callback extensions." diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index bca7619..b185649 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -241,6 +241,7 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: ) assert "Deployment project: alpha." in extended_description assert "Workspace root: /workspace." in extended_description + assert "currently return unsupported" in extended_description assert any("project alpha" in item for item in _build_chat_examples("alpha")) assert all( "shell" not in item @@ -397,6 +398,27 @@ async def body(self) -> bytes: await adapter._handle_streaming_request(_stream, _BrokenRequest()) +@pytest.mark.asyncio +async def test_push_notification_routes_are_explicitly_unsupported(monkeypatch) -> None: + monkeypatch.setattr( + app_module, + "OpencodeUpstreamClient", + DummyChatOpencodeUpstreamClient, + ) + app = create_app(make_settings(a2a_bearer_token="test-token")) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/v1/tasks/task-1/pushNotificationConfigs", + headers={"Authorization": "Bearer test-token"}, + json={"pushNotificationConfig": {"url": "https://example.com/hook"}}, + ) + + assert response.status_code == 501 + assert response.json() == {"message": "Push notifications are not supported by the agent"} + + @pytest.mark.asyncio async def test_on_cancel_task_and_resubscribe_cover_race_paths(monkeypatch) -> None: task_store = MagicMock() From a4021ec0ba52fe0ef203eb940a265f39b75aa0f0 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 3 Apr 2026 04:45:19 -0400 Subject: [PATCH 2/5] fix(runtime): align unsupported push notification contracts (#391) --- docs/guide.md | 6 +++--- tests/server/test_app_behaviors.py | 33 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index ce86139..0e63789 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -246,7 +246,7 @@ Current behavior: - `all_jsonrpc_methods` is the runtime truth for the current deployment. - The current SDK-owned core JSON-RPC surface includes `agent/getAuthenticatedExtendedCard` and `tasks/pushNotificationConfig/*`. - The current SDK-owned REST surface also includes `GET /v1/tasks` and the task push notification config routes. -- Push notification config routes/methods are currently exposed only because they are part of the SDK-owned core surface. This runtime does not configure a push config store or push sender, so push notification operations currently return `501` / unsupported. +- Push notification config routes/methods are currently exposed only because they are part of the SDK-owned core surface. This runtime does not configure a push config store or push sender, so push notification operations remain unsupported. REST routes currently return HTTP `501`, while JSON-RPC methods surface SDK-owned unsupported error envelopes. When `A2A_ENABLE_SESSION_SHELL=false`, `opencode.sessions.shell` is omitted from `all_jsonrpc_methods` and exposed only through `extensions.conditionally_available_methods`. @@ -287,7 +287,7 @@ Current compatibility matrix: | Transport payloads and enums | Supported | Partial | Request/response payloads, enums, and schema details still follow the SDK-owned `0.3` baseline. | | Error model | Supported | Partial | `0.3` keeps legacy `error.data={...}` / flat REST payloads; `1.0` uses protocol-aware JSON-RPC details and AIP-193-style REST errors. | | Pagination and list semantics | Supported | Partial | Cursor/list behavior is stable, but the declared shape still follows the `0.3` SDK baseline. | -| Push notification surfaces | Unsupported | Unsupported | SDK-owned task push-notification routes are still exposed, but this runtime does not enable push sender/config-store support and currently returns `501` / unsupported. | +| Push notification surfaces | Unsupported | Unsupported | SDK-owned task push-notification routes are still exposed, but this runtime does not enable push sender/config-store support. REST routes return HTTP `501`, while JSON-RPC methods remain unsupported via SDK-owned error envelopes. | | Signatures and authenticated data | Supported | Partial | Security schemes and authenticated extended card discovery follow the shipped SDK schema rather than a dedicated `1.0` compatibility layer. | ## Compatibility Profile @@ -565,7 +565,7 @@ This service exposes OpenCode session lifecycle inspection, list/message-history - Privacy guard: when `A2A_LOG_PAYLOADS=true`, request/response bodies are still suppressed for `method=opencode.sessions.*` - Endpoint discovery: prefer `additional_interfaces[]` with `transport=jsonrpc` from Agent Card - The runtime still delegates SDK-owned JSON-RPC methods such as `agent/getAuthenticatedExtendedCard` and `tasks/pushNotificationConfig/*` to the base A2A implementation; they are not OpenCode-specific extensions. -- Push notification config methods remain effectively unsupported in the current runtime because no push config store or push sender is configured. +- Push notification config methods remain effectively unsupported in the current runtime because no push config store or push sender is configured; REST routes return HTTP `501`, while JSON-RPC methods stay on SDK-owned unsupported error handling. - Notification behavior: for `opencode.sessions.*`, requests without `id` return HTTP `204 No Content` - Result format: - `opencode.sessions.status` => provider-private status summaries in `result.items` diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index b185649..c80cd32 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -419,6 +419,39 @@ async def test_push_notification_routes_are_explicitly_unsupported(monkeypatch) assert response.json() == {"message": "Push notifications are not supported by the agent"} +@pytest.mark.asyncio +async def test_push_notification_jsonrpc_methods_remain_unsupported(monkeypatch) -> None: + monkeypatch.setattr( + app_module, + "OpencodeUpstreamClient", + DummyChatOpencodeUpstreamClient, + ) + app = create_app(make_settings(a2a_bearer_token="test-token")) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/", + headers={"Authorization": "Bearer test-token"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tasks/pushNotificationConfig/get", + "params": {"id": "task-1"}, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "error": { + "code": -32004, + "message": "This operation is not supported", + }, + "id": 1, + "jsonrpc": "2.0", + } + + @pytest.mark.asyncio async def test_on_cancel_task_and_resubscribe_cover_race_paths(monkeypatch) -> None: task_store = MagicMock() From 2e70c9c57a9b9df0b8e9bef17d6888136399dfbc Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 3 Apr 2026 05:04:51 -0400 Subject: [PATCH 3/5] fix(runtime): gate workspace mutation extensions by default (#393) --- docs/extension-specifications.md | 2 +- docs/guide.md | 58 ++++++++-- src/opencode_a2a/config.py | 4 + src/opencode_a2a/contracts/extensions.py | 85 ++++++++++++--- src/opencode_a2a/jsonrpc/application.py | 10 +- src/opencode_a2a/jsonrpc/dispatch.py | 40 ++++--- .../jsonrpc/handlers/workspace_control.py | 19 ++-- src/opencode_a2a/profile/runtime.py | 24 ++++ src/opencode_a2a/sandbox_policy.py | 13 +++ src/opencode_a2a/server/agent_card.py | 22 ++-- src/opencode_a2a/server/openapi.py | 103 +++++++++--------- .../test_extension_contract_consistency.py | 6 +- .../test_jsonrpc_unsupported_method.py | 27 +++++ ...st_opencode_workspace_control_extension.py | 43 +++++++- tests/profile/test_profile_runtime.py | 27 +++++ tests/server/test_agent_card.py | 103 +++++++++++++++--- tests/server/test_app_behaviors.py | 14 +++ 17 files changed, 471 insertions(+), 129 deletions(-) diff --git a/docs/extension-specifications.md b/docs/extension-specifications.md index ac50a58..f65018c 100644 --- a/docs/extension-specifications.md +++ b/docs/extension-specifications.md @@ -79,7 +79,7 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extension-specifications.md#opencode-workspace-control-v1` -- Scope: provider-private project, workspace, and worktree control-plane methods +- Scope: provider-private project/workspace/worktree discovery plus deployment-conditional operator mutation methods - Public Agent Card: capability declaration only - Authenticated extended card: full method contracts, error surface, and routing notes - Transport: A2A JSON-RPC extension methods diff --git a/docs/guide.md b/docs/guide.md index 0e63789..676db63 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -28,6 +28,7 @@ Key variables to understand protocol behavior: - `OPENCODE_WORKSPACE_ROOT`: service-level default workspace root exposed to OpenCode when clients do not request a narrower directory override. - `A2A_ALLOW_DIRECTORY_OVERRIDE`: controls whether clients may pass `metadata.opencode.directory`. - `A2A_ENABLE_SESSION_SHELL`: gates high-risk JSON-RPC method `opencode.sessions.shell`. +- `A2A_ENABLE_WORKSPACE_MUTATIONS`: gates operator-only workspace/worktree mutation methods such as `opencode.workspaces.create` and `opencode.worktrees.reset`. - `A2A_SANDBOX_MODE` / `A2A_SANDBOX_FILESYSTEM_SCOPE` / `A2A_SANDBOX_WRITABLE_ROOTS`: declarative execution-boundary metadata for sandbox mode, filesystem scope, and optional writable roots. - `A2A_NETWORK_ACCESS` / `A2A_NETWORK_ALLOWED_DOMAINS`: declarative execution-boundary metadata for network policy and optional allowlist disclosure. - `A2A_APPROVAL_POLICY` / `A2A_APPROVAL_ESCALATION_BEHAVIOR`: declarative execution-boundary metadata for approval workflow. @@ -250,6 +251,8 @@ Current behavior: When `A2A_ENABLE_SESSION_SHELL=false`, `opencode.sessions.shell` is omitted from `all_jsonrpc_methods` and exposed only through `extensions.conditionally_available_methods`. +When `A2A_ENABLE_WORKSPACE_MUTATIONS=false`, `opencode.workspaces.create/remove` and `opencode.worktrees.create/remove/reset` are omitted from `all_jsonrpc_methods` and exposed only through `extensions.conditionally_available_methods`. + Unsupported method contract: - JSON-RPC error code: `-32601` @@ -970,14 +973,17 @@ Response: ## Workspace Control (Provider-Private Extension) -The runtime also exposes the OpenCode project/workspace/worktree control plane through provider-private JSON-RPC methods: +The runtime exposes OpenCode project/workspace/worktree discovery through provider-private JSON-RPC methods: - `opencode.projects.list` - `opencode.projects.current` - `opencode.workspaces.list` +- `opencode.worktrees.list` + +Deployment-conditional mutation methods remain available for trusted operator scenarios, but they are disabled by default. Enable them with `A2A_ENABLE_WORKSPACE_MUTATIONS=true`: + - `opencode.workspaces.create` - `opencode.workspaces.remove` -- `opencode.worktrees.list` - `opencode.worktrees.create` - `opencode.worktrees.remove` - `opencode.worktrees.reset` @@ -986,7 +992,7 @@ Behavior notes: - These methods target the active OpenCode deployment project. They are not routed through per-request workspace forwarding. - `metadata.opencode.workspace.id` is declared consistently across the adapter, but current workspace-control methods do not use it to change the target project. -- Mutating methods should be treated as operator-only control-plane actions. +- Mutating methods should be treated as operator-only control-plane actions and are disabled by default. ### Project Discovery (`opencode.projects.list`, `opencode.projects.current`) @@ -1007,7 +1013,27 @@ Response: - `opencode.projects.list` => `{"items": [...]}` - `opencode.projects.current` => `{"item": {...}}` -### Workspace Discovery and Mutation +### Workspace Discovery + +```bash +curl -sS http://127.0.0.1:8000/ \ + -H 'content-type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "jsonrpc": "2.0", + "id": 32, + "method": "opencode.workspaces.list", + "params": {} + }' +``` + +Response: + +- `opencode.workspaces.list` => `{"items": [...]}` + +### Workspace Mutation + +`opencode.workspaces.create` and `opencode.workspaces.remove` are disabled by default. Enable with `A2A_ENABLE_WORKSPACE_MUTATIONS=true`. ```bash curl -sS http://127.0.0.1:8000/ \ @@ -1029,11 +1055,30 @@ curl -sS http://127.0.0.1:8000/ \ Response: -- `opencode.workspaces.list` => `{"items": [...]}` - `opencode.workspaces.create` => `{"item": {...}}` - `opencode.workspaces.remove` => `{"item": {...}}` -### Worktree Discovery and Mutation +### Worktree Discovery + +```bash +curl -sS http://127.0.0.1:8000/ \ + -H 'content-type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "jsonrpc": "2.0", + "id": 33, + "method": "opencode.worktrees.list", + "params": {} + }' +``` + +Response: + +- `opencode.worktrees.list` => `{"items": [...]}` + +### Worktree Mutation + +`opencode.worktrees.create`, `opencode.worktrees.remove`, and `opencode.worktrees.reset` are disabled by default. Enable with `A2A_ENABLE_WORKSPACE_MUTATIONS=true`. ```bash curl -sS http://127.0.0.1:8000/ \ @@ -1053,7 +1098,6 @@ curl -sS http://127.0.0.1:8000/ \ Response: -- `opencode.worktrees.list` => `{"items": [...]}` - `opencode.worktrees.create` => `{"item": {...}}` - `opencode.worktrees.remove` => `{"ok": true|false}` - `opencode.worktrees.reset` => `{"ok": true|false}` diff --git a/src/opencode_a2a/config.py b/src/opencode_a2a/config.py index f7300b6..80ccb91 100644 --- a/src/opencode_a2a/config.py +++ b/src/opencode_a2a/config.py @@ -122,6 +122,10 @@ class Settings(BaseSettings): a2a_documentation_url: str | None = Field(default=None, alias="A2A_DOCUMENTATION_URL") a2a_allow_directory_override: bool = Field(default=True, alias="A2A_ALLOW_DIRECTORY_OVERRIDE") a2a_enable_session_shell: bool = Field(default=False, alias="A2A_ENABLE_SESSION_SHELL") + a2a_enable_workspace_mutations: bool = Field( + default=False, + alias="A2A_ENABLE_WORKSPACE_MUTATIONS", + ) a2a_sandbox_mode: SandboxMode = Field(default="unknown", alias="A2A_SANDBOX_MODE") a2a_sandbox_filesystem_scope: SandboxFilesystemScope = Field( default="unknown", diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index 2627a60..f2eeb13 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -5,7 +5,11 @@ from a2a.server.apps.jsonrpc.jsonrpc_app import JSONRPCApplication -from ..profile.runtime import SESSION_SHELL_TOGGLE, RuntimeProfile +from ..profile.runtime import ( + SESSION_SHELL_TOGGLE, + WORKSPACE_MUTATIONS_TOGGLE, + RuntimeProfile, +) EXTENSION_SPECIFICATIONS_DOCUMENT_URL = ( "https://github.com/Intelligent-Internet/opencode-a2a/blob/main/" @@ -580,6 +584,25 @@ class WorkspaceControlMethodContract: WORKSPACE_CONTROL_METHODS: dict[str, str] = { key: contract.method for key, contract in WORKSPACE_CONTROL_METHOD_CONTRACTS.items() } +WORKSPACE_DISCOVERY_METHOD_KEYS: tuple[str, ...] = ( + "list_projects", + "get_current_project", + "list_workspaces", + "list_worktrees", +) +WORKSPACE_DISCOVERY_METHODS: dict[str, str] = { + key: WORKSPACE_CONTROL_METHODS[key] for key in WORKSPACE_DISCOVERY_METHOD_KEYS +} +WORKSPACE_MUTATION_METHOD_KEYS: tuple[str, ...] = ( + "create_workspace", + "remove_workspace", + "create_worktree", + "remove_worktree", + "reset_worktree", +) +WORKSPACE_MUTATION_METHODS: dict[str, str] = { + key: WORKSPACE_CONTROL_METHODS[key] for key in WORKSPACE_MUTATION_METHOD_KEYS +} INTERRUPT_SUCCESS_RESULT_FIELDS: tuple[str, ...] = ("ok", "request_id") INTERRUPT_ERROR_BUSINESS_CODES: dict[str, int] = { @@ -716,14 +739,18 @@ def interrupt_callback_methods(self) -> dict[str, str]: return dict(INTERRUPT_CALLBACK_METHODS) def workspace_control_methods(self) -> dict[str, str]: - return dict(WORKSPACE_CONTROL_METHODS) + methods = dict(WORKSPACE_DISCOVERY_METHODS) + for key, method in WORKSPACE_MUTATION_METHODS.items(): + if self.is_method_enabled(method): + methods[key] = method + return methods def supported_jsonrpc_methods(self) -> list[str]: methods = [ *CORE_JSONRPC_METHODS, *(method for key, method in SESSION_QUERY_METHODS.items() if key != "shell"), *PROVIDER_DISCOVERY_METHODS.values(), - *WORKSPACE_CONTROL_METHODS.values(), + *self.workspace_control_methods().values(), *INTERRUPT_RECOVERY_METHODS.values(), *INTERRUPT_CALLBACK_METHODS.values(), ] @@ -735,7 +762,7 @@ def extension_jsonrpc_methods(self) -> list[str]: methods = [ *(method for key, method in SESSION_QUERY_METHODS.items() if key != "shell"), *PROVIDER_DISCOVERY_METHODS.values(), - *WORKSPACE_CONTROL_METHODS.values(), + *self.workspace_control_methods().values(), *INTERRUPT_RECOVERY_METHODS.values(), *INTERRUPT_CALLBACK_METHODS.values(), ] @@ -757,6 +784,13 @@ def control_method_flags(self) -> dict[str, dict[str, Any]]: if method in SESSION_CONTROL_METHODS.values() } + def workspace_mutation_method_flags(self) -> dict[str, dict[str, Any]]: + return { + method: conditional_method.control_method_flag() + for method, conditional_method in self.conditional_methods.items() + if method in WORKSPACE_MUTATION_METHODS.values() + } + def conditional_method_retention(self) -> dict[str, dict[str, Any]]: return { method: conditional_method.method_retention() @@ -765,16 +799,26 @@ def conditional_method_retention(self) -> dict[str, dict[str, Any]]: def build_capability_snapshot(*, runtime_profile: RuntimeProfile) -> JsonRpcCapabilitySnapshot: - return JsonRpcCapabilitySnapshot( - conditional_methods={ - SESSION_CONTROL_METHODS["shell"]: DeploymentConditionalMethod( - method=SESSION_CONTROL_METHODS["shell"], - enabled=runtime_profile.session_shell.enabled, - extension_uri=SESSION_QUERY_EXTENSION_URI, - toggle=SESSION_SHELL_TOGGLE, + conditional_methods = { + SESSION_CONTROL_METHODS["shell"]: DeploymentConditionalMethod( + method=SESSION_CONTROL_METHODS["shell"], + enabled=runtime_profile.session_shell.enabled, + extension_uri=SESSION_QUERY_EXTENSION_URI, + toggle=SESSION_SHELL_TOGGLE, + ) + } + conditional_methods.update( + { + method: DeploymentConditionalMethod( + method=method, + enabled=runtime_profile.workspace_mutations.enabled, + extension_uri=WORKSPACE_CONTROL_EXTENSION_URI, + toggle=WORKSPACE_MUTATIONS_TOGGLE, ) + for method in WORKSPACE_MUTATION_METHODS.values() } ) + return JsonRpcCapabilitySnapshot(conditional_methods=conditional_methods) def _build_method_contract_params( @@ -1239,9 +1283,14 @@ def build_workspace_control_extension_params( *, runtime_profile: RuntimeProfile, ) -> dict[str, Any]: + capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) + methods = capability_snapshot.workspace_control_methods() + active_workspace_methods = set(methods.values()) method_contracts: dict[str, Any] = {} for method_contract in WORKSPACE_CONTROL_METHOD_CONTRACTS.values(): + if method_contract.method not in active_workspace_methods: + continue params_contract = _build_method_contract_params( required=method_contract.required_params, optional=method_contract.optional_params, @@ -1261,7 +1310,8 @@ def build_workspace_control_extension_params( method_contracts[method_contract.method] = contract_doc return { - "methods": dict(WORKSPACE_CONTROL_METHODS), + "methods": methods, + "control_method_flags": capability_snapshot.workspace_mutation_method_flags(), "method_contracts": method_contracts, "supported_metadata": ["opencode.workspace.id", "opencode.directory"], "provider_private_metadata": ["opencode.workspace.id", "opencode.directory"], @@ -1280,6 +1330,10 @@ def build_workspace_control_extension_params( "Workspace control methods expose the OpenCode project/workspace/worktree " "control plane through provider-private JSON-RPC methods." ), + ( + "Mutation methods are deployment-conditional and disabled by default; " + "discover availability from the declared wire contract before calling them." + ), ( "Workspace routing metadata is declared for consistency, but the current " "control-plane methods operate on the active deployment project rather than " @@ -1346,7 +1400,7 @@ def build_compatibility_profile_params( "retention": "stable", "extension_uri": WORKSPACE_CONTROL_EXTENSION_URI, } - for method in WORKSPACE_CONTROL_METHODS.values() + for method in WORKSPACE_DISCOVERY_METHODS.values() } ) method_retention.update( @@ -1449,6 +1503,11 @@ def build_compatibility_profile_params( "Treat opencode.sessions.shell as deployment-conditional and discover it from " "the declared profile and current wire contract before calling it." ), + ( + "Treat opencode.workspaces.create/remove and opencode.worktrees.create/remove/" + "reset as deployment-conditional operator surfaces rather than baseline " + "workspace discovery methods." + ), ( "Treat declared service behaviors as stable server-level semantic " "enhancements layered on top of the core A2A method baseline." diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index f02c9bd..29097e4 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -103,12 +103,12 @@ def __init__( self._method_list_projects = methods["list_projects"] self._method_get_current_project = methods["get_current_project"] self._method_list_workspaces = methods["list_workspaces"] - self._method_create_workspace = methods["create_workspace"] - self._method_remove_workspace = methods["remove_workspace"] + self._method_create_workspace = methods.get("create_workspace") + self._method_remove_workspace = methods.get("remove_workspace") self._method_list_worktrees = methods["list_worktrees"] - self._method_create_worktree = methods["create_worktree"] - self._method_remove_worktree = methods["remove_worktree"] - self._method_reset_worktree = methods["reset_worktree"] + self._method_create_worktree = methods.get("create_worktree") + self._method_remove_worktree = methods.get("remove_worktree") + self._method_reset_worktree = methods.get("reset_worktree") self._method_list_permissions = methods["list_permissions"] self._method_list_questions = methods["list_questions"] self._method_reply_permission = methods["reply_permission"] diff --git a/src/opencode_a2a/jsonrpc/dispatch.py b/src/opencode_a2a/jsonrpc/dispatch.py index 0b31faf..9339918 100644 --- a/src/opencode_a2a/jsonrpc/dispatch.py +++ b/src/opencode_a2a/jsonrpc/dispatch.py @@ -52,12 +52,12 @@ class ExtensionHandlerContext: method_list_projects: str method_get_current_project: str method_list_workspaces: str - method_create_workspace: str - method_remove_workspace: str + method_create_workspace: str | None + method_remove_workspace: str | None method_list_worktrees: str - method_create_worktree: str - method_remove_worktree: str - method_reset_worktree: str + method_create_worktree: str | None + method_remove_worktree: str | None + method_reset_worktree: str | None method_list_permissions: str method_list_questions: str method_reply_permission: str @@ -137,6 +137,22 @@ def build_extension_method_registry( context.method_unrevert_session, } + workspace_control_methods = { + context.method_list_projects, + context.method_get_current_project, + context.method_list_workspaces, + context.method_list_worktrees, + } + for method in ( + context.method_create_workspace, + context.method_remove_workspace, + context.method_create_worktree, + context.method_remove_worktree, + context.method_reset_worktree, + ): + if method is not None: + workspace_control_methods.add(method) + return ExtensionMethodRegistry( ( ExtensionMethodSpec( @@ -176,19 +192,7 @@ def build_extension_method_registry( ), ExtensionMethodSpec( name="workspace_control", - methods=frozenset( - { - context.method_list_projects, - context.method_get_current_project, - context.method_list_workspaces, - context.method_create_workspace, - context.method_remove_workspace, - context.method_list_worktrees, - context.method_create_worktree, - context.method_remove_worktree, - context.method_reset_worktree, - } - ), + methods=frozenset(workspace_control_methods), handler=handle_workspace_control_request, ), ExtensionMethodSpec( diff --git a/src/opencode_a2a/jsonrpc/handlers/workspace_control.py b/src/opencode_a2a/jsonrpc/handlers/workspace_control.py index c846485..6959e15 100644 --- a/src/opencode_a2a/jsonrpc/handlers/workspace_control.py +++ b/src/opencode_a2a/jsonrpc/handlers/workspace_control.py @@ -122,18 +122,23 @@ async def handle_workspace_control_request( ) -> Response: del request - method_map = { + method_map: dict[str, str] = { context.method_list_projects: "list_projects", context.method_get_current_project: "get_current_project", context.method_list_workspaces: "list_workspaces", - context.method_create_workspace: "create_workspace", - context.method_remove_workspace: "remove_workspace", context.method_list_worktrees: "list_worktrees", - context.method_create_worktree: "create_worktree", - context.method_remove_worktree: "remove_worktree", - context.method_reset_worktree: "reset_worktree", } - method_key = method_map.get(base_request.method) + optional_methods = ( + (context.method_create_workspace, "create_workspace"), + (context.method_remove_workspace, "remove_workspace"), + (context.method_create_worktree, "create_worktree"), + (context.method_remove_worktree, "remove_worktree"), + (context.method_reset_worktree, "reset_worktree"), + ) + for method_name, optional_method_key in optional_methods: + if method_name is not None: + method_map[method_name] = optional_method_key + method_key: str | None = method_map.get(base_request.method) if method_key is None: return context.error_response( base_request.id, diff --git a/src/opencode_a2a/profile/runtime.py b/src/opencode_a2a/profile/runtime.py index e44863a..fe796ba 100644 --- a/src/opencode_a2a/profile/runtime.py +++ b/src/opencode_a2a/profile/runtime.py @@ -9,6 +9,7 @@ PROFILE_ID = "opencode-a2a-single-tenant-coding-v1" DEPLOYMENT_ID = "single_tenant_shared_workspace" SESSION_SHELL_TOGGLE = "A2A_ENABLE_SESSION_SHELL" +WORKSPACE_MUTATIONS_TOGGLE = "A2A_ENABLE_WORKSPACE_MUTATIONS" DIRECTORY_OVERRIDE_METADATA_FIELD = "metadata.opencode.directory" WORKSPACE_OVERRIDE_METADATA_FIELD = "metadata.opencode.workspace.id" @@ -73,6 +74,20 @@ def as_dict(self) -> dict[str, Any]: } +@dataclass(frozen=True) +class WorkspaceMutationsProfile: + enabled: bool + availability: str + toggle: str = WORKSPACE_MUTATIONS_TOGGLE + + def as_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "availability": self.availability, + "toggle": self.toggle, + } + + @dataclass(frozen=True) class ServiceFeaturesProfile: streaming: dict[str, Any] @@ -180,6 +195,7 @@ class RuntimeProfile: directory_binding: DirectoryBindingProfile workspace_binding: WorkspaceBindingProfile session_shell: SessionShellProfile + workspace_mutations: WorkspaceMutationsProfile execution_environment: ExecutionEnvironmentProfile service_features: ServiceFeaturesProfile runtime_context: RuntimeContext @@ -189,6 +205,7 @@ def runtime_features_dict(self) -> dict[str, Any]: "directory_binding": self.directory_binding.as_dict(), "workspace_binding": self.workspace_binding.as_dict(), "session_shell": self.session_shell.as_dict(), + "workspace_mutations": self.workspace_mutations.as_dict(), "execution_environment": self.execution_environment.as_dict(), "service_features": self.service_features.as_dict(), } @@ -226,6 +243,9 @@ def build_runtime_profile(settings: Settings) -> RuntimeProfile: shell_enabled = sandbox_policy.is_session_shell_enabled( enabled_by_config=settings.a2a_enable_session_shell, ) + workspace_mutations_enabled = sandbox_policy.is_workspace_mutations_enabled( + enabled_by_config=settings.a2a_enable_workspace_mutations, + ) directory_scope = ( "workspace_root_or_descendant" if settings.a2a_allow_directory_override @@ -245,6 +265,10 @@ def build_runtime_profile(settings: Settings) -> RuntimeProfile: enabled=shell_enabled, availability="enabled" if shell_enabled else "disabled", ), + workspace_mutations=WorkspaceMutationsProfile( + enabled=workspace_mutations_enabled, + availability="enabled" if workspace_mutations_enabled else "disabled", + ), execution_environment=ExecutionEnvironmentProfile( sandbox=SandboxProfile( mode=settings.a2a_sandbox_mode, diff --git a/src/opencode_a2a/sandbox_policy.py b/src/opencode_a2a/sandbox_policy.py index 2a56e8d..4e3954e 100644 --- a/src/opencode_a2a/sandbox_policy.py +++ b/src/opencode_a2a/sandbox_policy.py @@ -83,6 +83,19 @@ def is_session_shell_enabled( return False return True + def is_workspace_mutations_enabled( + self, + *, + enabled_by_config: bool, + ) -> bool: + if not enabled_by_config: + return False + if self.sandbox_mode == "read-only": + return False + if self.write_access_scope == "none": + return False + return True + def validate_configuration(self) -> None: if self.write_access_scope == "none" and self.writable_roots: raise ValueError( diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index a6647c4..1f825bc 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -190,12 +190,14 @@ def _build_interrupt_recovery_skill_examples() -> list[str]: ] -def _build_workspace_control_skill_examples() -> list[str]: - return [ +def _build_workspace_control_skill_examples(*, capability_snapshot) -> list[str]: # noqa: ANN001 + examples = [ "List OpenCode projects (method opencode.projects.list).", "List workspaces for the active project (method opencode.workspaces.list).", - "Create a worktree (method opencode.worktrees.create).", ] + if capability_snapshot.is_method_enabled("opencode.worktrees.create"): + examples.append("Create a worktree (method opencode.worktrees.create).") + return examples def _build_agent_extensions( @@ -325,8 +327,9 @@ def _build_agent_extensions( uri=WORKSPACE_CONTROL_EXTENSION_URI, required=False, description=( - "Expose OpenCode-specific project/workspace/worktree control-plane " - "methods through JSON-RPC extensions." + "Expose OpenCode-specific project/workspace/worktree discovery methods " + "plus deployment-conditional control-plane mutations through JSON-RPC " + "extensions." ), params=workspace_control_extension_params if include_detailed_contracts else None, ), @@ -505,13 +508,16 @@ def _build_agent_skills( id="opencode.workspace.control", name="OpenCode Workspace Control", description=( - "provider-private OpenCode project/workspace/worktree control surface " - "exposed through JSON-RPC extensions." + "provider-private OpenCode project/workspace/worktree discovery surface " + "with deployment-conditional mutation methods exposed through JSON-RPC " + "extensions." ), input_modes=list(_JSON_RPC_MODES), output_modes=list(_JSON_RPC_MODES), tags=["opencode", "project", "workspace", "worktree", "provider-private"], - examples=_build_workspace_control_skill_examples(), + examples=_build_workspace_control_skill_examples( + capability_snapshot=capability_snapshot, + ), ), AgentSkill( id="opencode.interrupt.recovery", diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index 87ff2c6..84d41eb 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -35,7 +35,7 @@ def _build_jsonrpc_extension_openapi_description( ) -> str: session_methods = list(capability_snapshot.session_query_methods().values()) provider_methods = ", ".join(sorted(PROVIDER_DISCOVERY_METHODS.values())) - workspace_methods = ", ".join(sorted(WORKSPACE_CONTROL_METHODS.values())) + workspace_methods = ", ".join(sorted(capability_snapshot.workspace_control_methods().values())) interrupt_recovery_methods = ", ".join(sorted(INTERRUPT_RECOVERY_METHODS.values())) interrupt_methods = ", ".join(sorted(INTERRUPT_CALLBACK_METHODS.values())) return ( @@ -391,24 +391,6 @@ def _build_jsonrpc_extension_openapi_examples( "params": {}, }, }, - "workspaces_create": { - "summary": "Create a workspace for the active project", - "value": { - "jsonrpc": "2.0", - "id": 291, - "method": WORKSPACE_CONTROL_METHODS["create_workspace"], - "params": {"request": {"type": "git", "branch": "main"}}, - }, - }, - "workspaces_remove": { - "summary": "Remove a workspace", - "value": { - "jsonrpc": "2.0", - "id": 292, - "method": WORKSPACE_CONTROL_METHODS["remove_workspace"], - "params": {"workspace_id": "wrk-1"}, - }, - }, "worktrees_list": { "summary": "List worktrees for the active project", "value": { @@ -418,38 +400,6 @@ def _build_jsonrpc_extension_openapi_examples( "params": {}, }, }, - "worktrees_create": { - "summary": "Create a new worktree", - "value": { - "jsonrpc": "2.0", - "id": 30, - "method": WORKSPACE_CONTROL_METHODS["create_worktree"], - "params": { - "request": { - "name": "feature-branch", - "startCommand": "pnpm install", - } - }, - }, - }, - "worktrees_remove": { - "summary": "Remove a worktree", - "value": { - "jsonrpc": "2.0", - "id": 301, - "method": WORKSPACE_CONTROL_METHODS["remove_worktree"], - "params": {"request": {"directory": "/tmp/worktrees/feature-branch"}}, - }, - }, - "worktrees_reset": { - "summary": "Reset a worktree branch", - "value": { - "jsonrpc": "2.0", - "id": 302, - "method": WORKSPACE_CONTROL_METHODS["reset_worktree"], - "params": {"request": {"directory": "/tmp/worktrees/feature-branch"}}, - }, - }, "permissions_list": { "summary": "List pending permission interrupts for the current caller", "value": { @@ -512,6 +462,57 @@ def _build_jsonrpc_extension_openapi_examples( }, }, } + if capability_snapshot.is_method_enabled(WORKSPACE_CONTROL_METHODS["create_workspace"]): + examples["workspaces_create"] = { + "summary": "Create a workspace for the active project", + "value": { + "jsonrpc": "2.0", + "id": 291, + "method": WORKSPACE_CONTROL_METHODS["create_workspace"], + "params": {"request": {"type": "git", "branch": "main"}}, + }, + } + examples["workspaces_remove"] = { + "summary": "Remove a workspace", + "value": { + "jsonrpc": "2.0", + "id": 292, + "method": WORKSPACE_CONTROL_METHODS["remove_workspace"], + "params": {"workspace_id": "wrk-1"}, + }, + } + examples["worktrees_create"] = { + "summary": "Create a new worktree", + "value": { + "jsonrpc": "2.0", + "id": 30, + "method": WORKSPACE_CONTROL_METHODS["create_worktree"], + "params": { + "request": { + "name": "feature-branch", + "startCommand": "pnpm install", + } + }, + }, + } + examples["worktrees_remove"] = { + "summary": "Remove a worktree", + "value": { + "jsonrpc": "2.0", + "id": 301, + "method": WORKSPACE_CONTROL_METHODS["remove_worktree"], + "params": {"request": {"directory": "/tmp/worktrees/feature-branch"}}, + }, + } + examples["worktrees_reset"] = { + "summary": "Reset a worktree branch", + "value": { + "jsonrpc": "2.0", + "id": 302, + "method": WORKSPACE_CONTROL_METHODS["reset_worktree"], + "params": {"request": {"directory": "/tmp/worktrees/feature-branch"}}, + }, + } return examples diff --git a/tests/contracts/test_extension_contract_consistency.py b/tests/contracts/test_extension_contract_consistency.py index 70d74fc..65e171a 100644 --- a/tests/contracts/test_extension_contract_consistency.py +++ b/tests/contracts/test_extension_contract_consistency.py @@ -5,7 +5,6 @@ INTERRUPT_CALLBACK_METHODS, SESSION_QUERY_DEFAULT_LIMIT, SESSION_QUERY_MAX_LIMIT, - WORKSPACE_CONTROL_METHODS, build_capability_snapshot, build_compatibility_profile_params, build_interrupt_callback_extension_params, @@ -245,7 +244,7 @@ def test_openapi_jsonrpc_contract_extension_matches_ssot() -> None: expected_methods |= { "opencode.providers.list", "opencode.models.list", - *WORKSPACE_CONTROL_METHODS.values(), + *workspace_control["methods"].values(), "opencode.permissions.list", "opencode.questions.list", } @@ -267,12 +266,15 @@ def test_openapi_jsonrpc_examples_use_declared_default_session_limit() -> None: @pytest.mark.asyncio @pytest.mark.parametrize("session_shell_enabled", [False, True]) +@pytest.mark.parametrize("workspace_mutations_enabled", [False, True]) async def test_runtime_supported_methods_align_with_capability_snapshot( session_shell_enabled: bool, + workspace_mutations_enabled: bool, ) -> None: settings = make_settings( a2a_bearer_token="test-token", a2a_enable_session_shell=session_shell_enabled, + a2a_enable_workspace_mutations=workspace_mutations_enabled, ) app = create_app(settings) runtime_profile = build_runtime_profile(settings) diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index bc98f96..02f6348 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -220,3 +220,30 @@ async def test_policy_disabled_shell_reports_current_supported_methods() -> None assert error["data"]["type"] == "METHOD_NOT_SUPPORTED" assert error["data"]["method"] == "opencode.sessions.shell" assert "opencode.sessions.shell" not in error["data"]["supported_methods"] + + +@pytest.mark.asyncio +async def test_disabled_workspace_mutation_reports_current_supported_methods() -> None: + settings = make_settings(a2a_bearer_token="test-token") + app = create_app(settings) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/", + headers={"Authorization": "Bearer test-token"}, + json={ + "jsonrpc": "2.0", + "id": 126, + "method": "opencode.workspaces.create", + "params": {"request": {"type": "git"}}, + }, + ) + + assert response.status_code == 200 + body = response.json() + error = body["error"] + assert error["code"] == -32601 + assert error["data"]["type"] == "METHOD_NOT_SUPPORTED" + assert error["data"]["method"] == "opencode.workspaces.create" + assert "opencode.workspaces.create" not in error["data"]["supported_methods"] diff --git a/tests/jsonrpc/test_opencode_workspace_control_extension.py b/tests/jsonrpc/test_opencode_workspace_control_extension.py index 112531b..cca750e 100644 --- a/tests/jsonrpc/test_opencode_workspace_control_extension.py +++ b/tests/jsonrpc/test_opencode_workspace_control_extension.py @@ -68,7 +68,12 @@ async def test_workspace_control_extension_supports_mutating_methods(monkeypatch ) monkeypatch.setattr(app_module, "OpencodeUpstreamClient", lambda _settings: dummy) app = app_module.create_app( - make_settings(a2a_bearer_token="t-1", a2a_log_payloads=False, **_BASE_SETTINGS) + make_settings( + a2a_bearer_token="t-1", + a2a_log_payloads=False, + a2a_enable_workspace_mutations=True, + **_BASE_SETTINGS, + ) ) transport = httpx.ASGITransport(app=app) @@ -143,7 +148,12 @@ async def test_workspace_control_extension_validates_request_shape(monkeypatch) monkeypatch.setattr(app_module, "OpencodeUpstreamClient", DummyOpencodeUpstreamClient) app = app_module.create_app( - make_settings(a2a_bearer_token="t-1", a2a_log_payloads=False, **_BASE_SETTINGS) + make_settings( + a2a_bearer_token="t-1", + a2a_log_payloads=False, + a2a_enable_workspace_mutations=True, + **_BASE_SETTINGS, + ) ) transport = httpx.ASGITransport(app=app) @@ -165,6 +175,35 @@ async def test_workspace_control_extension_validates_request_shape(monkeypatch) assert payload["error"]["data"]["field"] == "request" +@pytest.mark.asyncio +async def test_workspace_control_mutations_are_disabled_by_default(monkeypatch) -> None: + import opencode_a2a.server.application as app_module + + monkeypatch.setattr(app_module, "OpencodeUpstreamClient", DummyOpencodeUpstreamClient) + app = app_module.create_app( + make_settings(a2a_bearer_token="t-1", a2a_log_payloads=False, **_BASE_SETTINGS) + ) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/", + headers={"Authorization": "Bearer t-1"}, + json={ + "jsonrpc": "2.0", + "id": 22, + "method": "opencode.worktrees.create", + "params": {"request": {"name": "feature-branch"}}, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["error"]["code"] == -32601 + assert payload["error"]["data"]["type"] == "METHOD_NOT_SUPPORTED" + assert payload["error"]["data"]["method"] == "opencode.worktrees.create" + + @pytest.mark.asyncio async def test_workspace_control_extension_maps_upstream_http_error(monkeypatch) -> None: import opencode_a2a.server.application as app_module diff --git a/tests/profile/test_profile_runtime.py b/tests/profile/test_profile_runtime.py index d64586e..8c4de14 100644 --- a/tests/profile/test_profile_runtime.py +++ b/tests/profile/test_profile_runtime.py @@ -50,6 +50,11 @@ def test_profile_runtime_splits_deployment_runtime_features_and_health_payload() "availability": "disabled", "toggle": "A2A_ENABLE_SESSION_SHELL", }, + "workspace_mutations": { + "enabled": False, + "availability": "disabled", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, "execution_environment": { "sandbox": { "mode": "workspace-write", @@ -121,6 +126,11 @@ def test_profile_runtime_uses_conservative_execution_environment_defaults() -> N "outside_workspace": "unknown", }, } + assert profile.runtime_features_dict()["workspace_mutations"] == { + "enabled": False, + "availability": "disabled", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + } def test_profile_runtime_disables_shell_when_policy_is_read_only() -> None: @@ -138,3 +148,20 @@ def test_profile_runtime_disables_shell_when_policy_is_read_only() -> None: "availability": "disabled", "toggle": "A2A_ENABLE_SESSION_SHELL", } + + +def test_profile_runtime_disables_workspace_mutations_when_policy_is_read_only() -> None: + settings = make_settings( + a2a_bearer_token="test-token", + a2a_enable_workspace_mutations=True, + a2a_sandbox_mode="read-only", + a2a_write_access_scope="workspace_only", + ) + + profile = build_runtime_profile(settings) + + assert profile.runtime_features_dict()["workspace_mutations"] == { + "enabled": False, + "availability": "disabled", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + } diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 9c3992d..8f1da33 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -522,12 +522,11 @@ def test_agent_card_injects_profile_into_extensions() -> None: "list_projects": "opencode.projects.list", "get_current_project": "opencode.projects.current", "list_workspaces": "opencode.workspaces.list", - "create_workspace": "opencode.workspaces.create", - "remove_workspace": "opencode.workspaces.remove", "list_worktrees": "opencode.worktrees.list", - "create_worktree": "opencode.worktrees.create", - "remove_worktree": "opencode.worktrees.remove", - "reset_worktree": "opencode.worktrees.reset", + } + assert workspace_control.params["control_method_flags"]["opencode.workspaces.create"] == { + "enabled_by_default": False, + "config_key": "A2A_ENABLE_WORKSPACE_MUTATIONS", } assert workspace_control.params["routing_fields"]["workspace_id"] == ( "metadata.opencode.workspace.id" @@ -536,14 +535,8 @@ def test_agent_card_injects_profile_into_extensions() -> None: "fields": ["items"], "items_type": "Project[]", } - assert workspace_control.params["method_contracts"]["opencode.workspaces.create"]["params"] == { - "required": ["request.type"], - "optional": ["request.id", "request.branch", "request.extra"], - } - assert workspace_control.params["method_contracts"]["opencode.worktrees.reset"]["result"] == { - "fields": ["ok"], - "items_type": "boolean", - } + assert "opencode.workspaces.create" not in workspace_control.params["method_contracts"] + assert "opencode.worktrees.reset" not in workspace_control.params["method_contracts"] interrupt_recovery = ext_by_uri[INTERRUPT_RECOVERY_EXTENSION_URI] assert interrupt_recovery.params["profile"]["runtime_context"]["project"] == "alpha" @@ -629,11 +622,20 @@ def test_agent_card_injects_profile_into_extensions() -> None: "retention": "stable", } shell_policy = compatibility.params["method_retention"]["opencode.sessions.shell"] + workspace_mutation_policy = compatibility.params["method_retention"][ + "opencode.workspaces.create" + ] assert compatibility.params["deployment"]["id"] == "single_tenant_shared_workspace" assert compatibility.params["runtime_features"]["session_shell"]["availability"] == "disabled" + assert compatibility.params["runtime_features"]["workspace_mutations"]["availability"] == ( + "disabled" + ) assert shell_policy["availability"] == "disabled" assert shell_policy["retention"] == "deployment-conditional" assert shell_policy["toggle"] == "A2A_ENABLE_SESSION_SHELL" + assert workspace_mutation_policy["availability"] == "disabled" + assert workspace_mutation_policy["retention"] == "deployment-conditional" + assert workspace_mutation_policy["toggle"] == "A2A_ENABLE_WORKSPACE_MUTATIONS" assert compatibility.params["method_retention"]["agent/getAuthenticatedExtendedCard"] == { "surface": "core", "availability": "always", @@ -687,7 +689,27 @@ def test_agent_card_injects_profile_into_extensions() -> None: "opencode.sessions.shell": { "reason": "disabled_by_configuration", "toggle": "A2A_ENABLE_SESSION_SHELL", - } + }, + "opencode.workspaces.create": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.workspaces.remove": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.worktrees.create": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.worktrees.remove": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.worktrees.reset": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, } assert wire_contract.description.endswith("unified error contracts.") @@ -723,12 +745,63 @@ def test_agent_card_contracts_include_shell_when_enabled() -> None: "enabled" ) assert "opencode.sessions.shell" in wire_contract.params["all_jsonrpc_methods"] - assert wire_contract.params["extensions"]["conditionally_available_methods"] == {} + assert wire_contract.params["extensions"]["conditionally_available_methods"] == { + "opencode.workspaces.create": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.workspaces.remove": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.worktrees.create": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.worktrees.remove": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + "opencode.worktrees.reset": { + "reason": "disabled_by_configuration", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, + } session_skill = next(skill for skill in card.skills if skill.id == "opencode.sessions.query") assert any("opencode.sessions.shell" in example for example in session_skill.examples) +def test_agent_card_contracts_include_workspace_mutations_when_enabled() -> None: + card = build_authenticated_extended_agent_card( + make_settings(a2a_bearer_token="test-token", a2a_enable_workspace_mutations=True) + ) + ext_by_uri = {ext.uri: ext for ext in card.capabilities.extensions or []} + + workspace_control = ext_by_uri[WORKSPACE_CONTROL_EXTENSION_URI] + assert workspace_control.params["methods"]["create_workspace"] == "opencode.workspaces.create" + assert workspace_control.params["methods"]["create_worktree"] == "opencode.worktrees.create" + assert "opencode.workspaces.create" in workspace_control.params["method_contracts"] + assert "opencode.worktrees.reset" in workspace_control.params["method_contracts"] + + compatibility = ext_by_uri[COMPATIBILITY_PROFILE_EXTENSION_URI] + workspace_mutation_policy = compatibility.params["method_retention"][ + "opencode.workspaces.create" + ] + assert compatibility.params["runtime_features"]["workspace_mutations"]["availability"] == ( + "enabled" + ) + assert workspace_mutation_policy["availability"] == "enabled" + + wire_contract = ext_by_uri[WIRE_CONTRACT_EXTENSION_URI] + assert ( + wire_contract.params["profile"]["runtime_features"]["workspace_mutations"]["availability"] + == "enabled" + ) + assert "opencode.workspaces.create" in wire_contract.params["all_jsonrpc_methods"] + assert "opencode.worktrees.reset" in wire_contract.params["all_jsonrpc_methods"] + + def test_agent_card_skills_hide_shell_when_disabled_by_default() -> None: card = build_agent_card(make_settings(a2a_bearer_token="test-token")) diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index c80cd32..53cbd08 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -189,6 +189,11 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: "availability": "enabled", "toggle": "A2A_ENABLE_SESSION_SHELL", }, + "workspace_mutations": { + "enabled": False, + "availability": "disabled", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, "execution_environment": { "sandbox": { "mode": "unknown", @@ -259,6 +264,9 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: assert "session_shell" in _build_jsonrpc_extension_openapi_examples( capability_snapshot=capability_snapshot ) + assert "worktrees_create" not in _build_jsonrpc_extension_openapi_examples( + capability_snapshot=capability_snapshot + ) assert "continue_session" in _build_rest_message_openapi_examples() @@ -321,6 +329,11 @@ async def close(self) -> None: "availability": "enabled", "toggle": "A2A_ENABLE_SESSION_SHELL", }, + "workspace_mutations": { + "enabled": False, + "availability": "disabled", + "toggle": "A2A_ENABLE_WORKSPACE_MUTATIONS", + }, "execution_environment": { "sandbox": { "mode": "unknown", @@ -370,6 +383,7 @@ async def close(self) -> None: "application/json" ]["examples"] assert "session_shell" in root_examples + assert "worktrees_create" not in root_examples assert "opencode.sessions.shell" in openapi_first["paths"]["/"]["post"]["description"] From 198abb5725e8a84ad3a1d9ed490f92c228945907 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Mon, 6 Apr 2026 22:30:52 -0400 Subject: [PATCH 4/5] refactor(contracts): correct session management extension taxonomy (#394) --- docs/extension-specifications.md | 8 +- docs/guide.md | 6 +- src/opencode_a2a/contracts/extensions.py | 70 ++++++----- src/opencode_a2a/jsonrpc/application.py | 4 +- src/opencode_a2a/jsonrpc/dispatch.py | 6 +- src/opencode_a2a/server/agent_card.py | 40 +++---- src/opencode_a2a/server/application.py | 18 +-- src/opencode_a2a/server/openapi.py | 50 ++++---- src/opencode_a2a/server/request_parsing.py | 4 +- .../test_extension_contract_consistency.py | 26 ++-- .../test_jsonrpc_unsupported_method.py | 3 +- tests/server/test_agent_card.py | 111 ++++++++++-------- tests/server/test_app_behaviors.py | 14 +-- tests/server/test_transport_contract.py | 12 +- 14 files changed, 200 insertions(+), 172 deletions(-) diff --git a/docs/extension-specifications.md b/docs/extension-specifications.md index f65018c..afce7fb 100644 --- a/docs/extension-specifications.md +++ b/docs/extension-specifications.md @@ -37,13 +37,13 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens - Authenticated extended card: full shared stream contract including detailed block payload mappings and extended usage metadata - Runtime fields: `metadata.shared.stream`, `metadata.shared.usage`, `metadata.shared.interrupt`, `metadata.shared.session` -## OpenCode Session Query v1 +## OpenCode Session Management v1 -URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extension-specifications.md#opencode-session-query-v1` +URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extension-specifications.md#opencode-session-management-v1` -- Scope: provider-private OpenCode session lifecycle, history, and low-risk control methods +- Scope: provider-private OpenCode session read, mutation, and control methods - Public Agent Card: capability declaration only -- Authenticated extended card: full method matrix, pagination rules, errors, context semantics, and existing `opencode.sessions.prompt_async` input-part contracts +- Authenticated extended card: full method matrix, read/mutation/control grouping, pagination rules, errors, context semantics, and existing `opencode.sessions.prompt_async` input-part contracts - Transport: A2A JSON-RPC extension methods - `opencode.sessions.prompt_async` includes a provider-private `request.parts[]` compatibility surface for upstream OpenCode part types `text`, `file`, `agent`, and `subtask` - `subtask` support is declared as passthrough-compatible only: subagent selection and task-tool execution remain upstream OpenCode runtime behavior, not a separate `opencode-a2a` orchestration API diff --git a/docs/guide.md b/docs/guide.md index 676db63..b9b3557 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -559,9 +559,9 @@ Minimal stream semantics summary: - `sequence` is the per-request canonical stream sequence - final task/status metadata may repeat normalized usage and interrupt context even after the streaming phase ends -## OpenCode Session Query A2A Extension +## OpenCode Session Management A2A Extension -This service exposes OpenCode session lifecycle inspection, list/message-history queries, and low-risk session control methods via A2A JSON-RPC extension methods (default endpoint: `POST /`). No extra custom REST endpoint is introduced. +This service exposes OpenCode session read, mutation, and control methods via A2A JSON-RPC extension methods (default endpoint: `POST /`). No extra custom REST endpoint is introduced. - Trigger: call extension methods through A2A JSON-RPC - Auth: same `Authorization: Bearer ` @@ -594,7 +594,7 @@ This service exposes OpenCode session lifecycle inspection, list/message-history - optional `limit`, `before` - optional `metadata.opencode.workspace.id` - `before` is an opaque cursor for loading older messages and is only supported on `opencode.sessions.messages.list` -- Mutating lifecycle methods: +- Mutation methods: - `opencode.sessions.fork` - `opencode.sessions.share` - `opencode.sessions.unshare` diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index f2eeb13..c9c4bf6 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -34,7 +34,7 @@ def _extension_spec_uri(fragment: str) -> str: SESSION_BINDING_EXTENSION_URI = _extension_spec_uri("shared-session-binding-v1") MODEL_SELECTION_EXTENSION_URI = _extension_spec_uri("shared-model-selection-v1") STREAMING_EXTENSION_URI = _extension_spec_uri("shared-stream-hints-v1") -SESSION_QUERY_EXTENSION_URI = _extension_spec_uri("opencode-session-query-v1") +SESSION_MANAGEMENT_EXTENSION_URI = _extension_spec_uri("opencode-session-management-v1") PROVIDER_DISCOVERY_EXTENSION_URI = _extension_spec_uri("opencode-provider-discovery-v1") INTERRUPT_CALLBACK_EXTENSION_URI = _extension_spec_uri("shared-interactive-interrupt-v1") INTERRUPT_RECOVERY_EXTENSION_URI = _extension_spec_uri("opencode-interrupt-recovery-v1") @@ -160,7 +160,7 @@ class WorkspaceControlMethodContract: SESSION_QUERY_PAGINATION_PARAMS: tuple[str, ...] = ("limit", "before") SESSION_QUERY_PAGINATION_UNSUPPORTED: tuple[str, ...] = ("cursor", "page", "size") -SESSION_QUERY_METHOD_CONTRACTS: dict[str, SessionQueryMethodContract] = { +SESSION_METHOD_CONTRACTS: dict[str, SessionQueryMethodContract] = { "status": SessionQueryMethodContract( method="opencode.sessions.status", optional_params=("directory", OPENCODE_WORKSPACE_METADATA_FIELD), @@ -388,20 +388,27 @@ class WorkspaceControlMethodContract: ), } -SESSION_QUERY_METHODS: dict[str, str] = { - key: contract.method for key, contract in SESSION_QUERY_METHOD_CONTRACTS.items() +SESSION_METHODS: dict[str, str] = { + key: contract.method for key, contract in SESSION_METHOD_CONTRACTS.items() } SESSION_CONTROL_METHOD_KEYS: tuple[str, ...] = ("prompt_async", "command", "shell") SESSION_CONTROL_METHODS: dict[str, str] = { - key: SESSION_QUERY_METHODS[key] for key in SESSION_CONTROL_METHOD_KEYS + key: SESSION_METHODS[key] for key in SESSION_CONTROL_METHOD_KEYS } -SESSION_LIFECYCLE_METHOD_KEYS: tuple[str, ...] = ( +SESSION_READ_METHOD_KEYS: tuple[str, ...] = ( "status", + "list_sessions", "get_session", "get_session_children", "get_session_todo", "get_session_diff", "get_session_message", + "get_session_messages", +) +SESSION_READ_METHODS: dict[str, str] = { + key: SESSION_METHODS[key] for key in SESSION_READ_METHOD_KEYS +} +SESSION_MUTATION_METHOD_KEYS: tuple[str, ...] = ( "fork", "share", "unshare", @@ -409,8 +416,8 @@ class WorkspaceControlMethodContract: "revert", "unrevert", ) -SESSION_LIFECYCLE_METHODS: dict[str, str] = { - key: SESSION_QUERY_METHODS[key] for key in SESSION_LIFECYCLE_METHOD_KEYS +SESSION_MUTATION_METHODS: dict[str, str] = { + key: SESSION_METHODS[key] for key in SESSION_MUTATION_METHOD_KEYS } CORE_JSONRPC_METHODS: tuple[str, ...] = tuple(JSONRPCApplication.METHOD_TO_MODEL) @@ -714,9 +721,9 @@ def is_method_enabled(self, method: str) -> bool: return True return conditional_method.enabled - def session_query_methods(self) -> dict[str, str]: - methods = dict(SESSION_QUERY_METHODS) - if not self.is_method_enabled(SESSION_QUERY_METHODS["shell"]): + def session_management_methods(self) -> dict[str, str]: + methods = dict(SESSION_METHODS) + if not self.is_method_enabled(SESSION_METHODS["shell"]): methods.pop("shell", None) return methods @@ -726,8 +733,11 @@ def session_control_methods(self) -> dict[str, str]: methods.pop("shell", None) return methods - def session_lifecycle_methods(self) -> dict[str, str]: - return dict(SESSION_LIFECYCLE_METHODS) + def session_read_methods(self) -> dict[str, str]: + return dict(SESSION_READ_METHODS) + + def session_mutation_methods(self) -> dict[str, str]: + return dict(SESSION_MUTATION_METHODS) def provider_discovery_methods(self) -> dict[str, str]: return dict(PROVIDER_DISCOVERY_METHODS) @@ -748,7 +758,7 @@ def workspace_control_methods(self) -> dict[str, str]: def supported_jsonrpc_methods(self) -> list[str]: methods = [ *CORE_JSONRPC_METHODS, - *(method for key, method in SESSION_QUERY_METHODS.items() if key != "shell"), + *(method for key, method in SESSION_METHODS.items() if key != "shell"), *PROVIDER_DISCOVERY_METHODS.values(), *self.workspace_control_methods().values(), *INTERRUPT_RECOVERY_METHODS.values(), @@ -760,7 +770,7 @@ def supported_jsonrpc_methods(self) -> list[str]: def extension_jsonrpc_methods(self) -> list[str]: methods = [ - *(method for key, method in SESSION_QUERY_METHODS.items() if key != "shell"), + *(method for key, method in SESSION_METHODS.items() if key != "shell"), *PROVIDER_DISCOVERY_METHODS.values(), *self.workspace_control_methods().values(), *INTERRUPT_RECOVERY_METHODS.values(), @@ -803,7 +813,7 @@ def build_capability_snapshot(*, runtime_profile: RuntimeProfile) -> JsonRpcCapa SESSION_CONTROL_METHODS["shell"]: DeploymentConditionalMethod( method=SESSION_CONTROL_METHODS["shell"], enabled=runtime_profile.session_shell.enabled, - extension_uri=SESSION_QUERY_EXTENSION_URI, + extension_uri=SESSION_MANAGEMENT_EXTENSION_URI, toggle=SESSION_SHELL_TOGGLE, ) } @@ -1018,22 +1028,23 @@ def build_streaming_extension_params() -> dict[str, Any]: } -def build_session_query_extension_params( +def build_session_management_extension_params( *, runtime_profile: RuntimeProfile, context_id_prefix: str, ) -> dict[str, Any]: capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) - methods = capability_snapshot.session_query_methods() + methods = capability_snapshot.session_management_methods() + read_methods = capability_snapshot.session_read_methods() + mutation_methods = capability_snapshot.session_mutation_methods() control_methods = capability_snapshot.session_control_methods() - lifecycle_methods = capability_snapshot.session_lifecycle_methods() - active_session_query_methods = set(methods.values()) + active_session_methods = set(methods.values()) method_contracts: dict[str, Any] = {} pagination_applies_to: list[str] = [] - for method_contract in SESSION_QUERY_METHOD_CONTRACTS.values(): - if method_contract.method not in active_session_query_methods: + for method_contract in SESSION_METHOD_CONTRACTS.values(): + if method_contract.method not in active_session_methods: continue params_contract = _build_method_contract_params( required=method_contract.required_params, @@ -1048,7 +1059,7 @@ def build_session_query_extension_params( "params": params_contract, "result": result_contract, } - if method_contract.method == SESSION_QUERY_METHODS["prompt_async"]: + if method_contract.method == SESSION_METHODS["prompt_async"]: contract_doc["request_parts"] = _build_prompt_async_part_contracts() contract_doc["subtask_support"] = _build_prompt_async_subtask_support() if method_contract.notification_response_status is not None: @@ -1062,8 +1073,9 @@ def build_session_query_extension_params( return { "methods": methods, + "read_methods": read_methods, + "mutation_methods": mutation_methods, "control_methods": control_methods, - "lifecycle_methods": lifecycle_methods, "control_method_flags": capability_snapshot.control_method_flags(), "profile": runtime_profile.summary_dict(), "pagination": { @@ -1075,7 +1087,7 @@ def build_session_query_extension_params( "cursor_param": "before", "result_cursor_field": "next_cursor", "applies_to": pagination_applies_to, - "cursor_applies_to": [SESSION_QUERY_METHODS["get_session_messages"]], + "cursor_applies_to": [SESSION_METHODS["get_session_messages"]], }, "method_contracts": method_contracts, "errors": { @@ -1374,9 +1386,9 @@ def build_compatibility_profile_params( "surface": "extension", "availability": "always", "retention": "stable", - "extension_uri": SESSION_QUERY_EXTENSION_URI, + "extension_uri": SESSION_MANAGEMENT_EXTENSION_URI, } - for key, method in SESSION_QUERY_METHODS.items() + for key, method in SESSION_METHODS.items() if key != "shell" } ) @@ -1450,7 +1462,7 @@ def build_compatibility_profile_params( "availability": "always", "retention": "required", }, - SESSION_QUERY_EXTENSION_URI: { + SESSION_MANAGEMENT_EXTENSION_URI: { "surface": "jsonrpc-extension", "availability": "always", "retention": "stable", @@ -1617,7 +1629,7 @@ def build_wire_contract_params( SESSION_BINDING_EXTENSION_URI, MODEL_SELECTION_EXTENSION_URI, STREAMING_EXTENSION_URI, - SESSION_QUERY_EXTENSION_URI, + SESSION_MANAGEMENT_EXTENSION_URI, PROVIDER_DISCOVERY_EXTENSION_URI, WORKSPACE_CONTROL_EXTENSION_URI, INTERRUPT_RECOVERY_EXTENSION_URI, diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index 29097e4..d927b51 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -59,8 +59,8 @@ ] -class OpencodeSessionQueryJSONRPCApplication(A2AFastAPIApplication): - """Extend A2A JSON-RPC endpoint with OpenCode session methods. +class OpencodeSessionManagementJSONRPCApplication(A2AFastAPIApplication): + """Extend A2A JSON-RPC endpoint with OpenCode session management methods. These methods are optional (declared via AgentCard.capabilities.extensions) and do not require additional private REST endpoints. diff --git a/src/opencode_a2a/jsonrpc/dispatch.py b/src/opencode_a2a/jsonrpc/dispatch.py index 9339918..b23af08 100644 --- a/src/opencode_a2a/jsonrpc/dispatch.py +++ b/src/opencode_a2a/jsonrpc/dispatch.py @@ -122,7 +122,7 @@ def build_extension_method_registry( session_control_methods = {context.method_prompt_async, context.method_command} if context.method_shell is not None: session_control_methods.add(context.method_shell) - session_lifecycle_methods = { + session_item_methods = { context.method_session_status, context.method_get_session, context.method_get_session_children, @@ -157,11 +157,11 @@ def build_extension_method_registry( ( ExtensionMethodSpec( name="session_lifecycle", - methods=frozenset(session_lifecycle_methods), + methods=frozenset(session_item_methods), handler=handle_session_lifecycle_request, ), ExtensionMethodSpec( - name="session_query", + name="session_listing", methods=frozenset( { context.method_list_sessions, diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index 1f825bc..dd564ff 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -21,8 +21,8 @@ MODEL_SELECTION_EXTENSION_URI, PROVIDER_DISCOVERY_EXTENSION_URI, SESSION_BINDING_EXTENSION_URI, - SESSION_QUERY_EXTENSION_URI, - SESSION_QUERY_METHODS, + SESSION_MANAGEMENT_EXTENSION_URI, + SESSION_METHODS, STREAMING_EXTENSION_URI, WIRE_CONTRACT_EXTENSION_URI, WORKSPACE_CONTROL_EXTENSION_URI, @@ -34,7 +34,7 @@ build_model_selection_extension_params, build_provider_discovery_extension_params, build_session_binding_extension_params, - build_session_query_extension_params, + build_session_management_extension_params, build_streaming_extension_params, build_wire_contract_params, build_workspace_control_extension_params, @@ -153,7 +153,7 @@ def _build_chat_examples(project: str | None) -> list[str]: return examples -def _build_session_query_skill_examples( +def _build_session_management_skill_examples( *, capability_snapshot: JsonRpcCapabilitySnapshot, ) -> list[str]: @@ -178,7 +178,7 @@ def _build_session_query_skill_examples( "opencode.sessions.revert / opencode.sessions.unrevert)." ), ] - if capability_snapshot.is_method_enabled(SESSION_QUERY_METHODS["shell"]): + if capability_snapshot.is_method_enabled(SESSION_METHODS["shell"]): examples.append("Run shell in a session (method opencode.sessions.shell).") return examples @@ -213,7 +213,7 @@ def _build_agent_extensions( runtime_profile=runtime_profile, ) streaming_extension_params = build_streaming_extension_params() - session_query_extension_params = build_session_query_extension_params( + session_management_extension_params = build_session_management_extension_params( runtime_profile=runtime_profile, context_id_prefix=SESSION_CONTEXT_PREFIX, ) @@ -305,14 +305,14 @@ def _build_agent_extensions( ), ), AgentExtension( - uri=SESSION_QUERY_EXTENSION_URI, + uri=SESSION_MANAGEMENT_EXTENSION_URI, required=False, description=( - "Support OpenCode session lifecycle inspection, history queries, low-risk " - "session management, and async prompt injection via custom JSON-RPC " - "methods on the agent's A2A JSON-RPC interface." + "Support OpenCode session read, mutation, and control methods through " + "provider-private JSON-RPC extensions on the agent's A2A JSON-RPC " + "interface." ), - params=session_query_extension_params if include_detailed_contracts else None, + params=session_management_extension_params if include_detailed_contracts else None, ), AgentExtension( uri=PROVIDER_DISCOVERY_EXTENSION_URI, @@ -403,11 +403,11 @@ def _build_agent_skills( tags=["assistant", "coding", "opencode", "core-a2a", "portable"], ), AgentSkill( - id="opencode.sessions.query", - name="OpenCode Sessions Query", + id="opencode.sessions.management", + name="OpenCode Session Management", description=( - "Inspect OpenCode session status, history, and low-risk lifecycle actions " - "through provider-private JSON-RPC extensions." + "Read, mutate, and control OpenCode sessions through provider-private " + "JSON-RPC extensions." ), input_modes=list(_JSON_RPC_MODES), output_modes=list(_JSON_RPC_MODES), @@ -476,16 +476,16 @@ def _build_agent_skills( examples=_build_chat_examples(settings.a2a_project), ), AgentSkill( - id="opencode.sessions.query", - name="OpenCode Sessions Query", + id="opencode.sessions.management", + name="OpenCode Session Management", description=( - "provider-private OpenCode session/history and session-control surface " - "exposed through JSON-RPC extensions." + "provider-private OpenCode session read/mutation/control surface exposed " + "through JSON-RPC extensions." ), input_modes=list(_JSON_RPC_MODES), output_modes=list(_JSON_RPC_MODES), tags=["opencode", "sessions", "history", "provider-private"], - examples=_build_session_query_skill_examples( + examples=_build_session_management_skill_examples( capability_snapshot=capability_snapshot, ), ), diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index 5e74624..6918351 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -50,8 +50,8 @@ PROVIDER_DISCOVERY_METHODS, SESSION_BINDING_EXTENSION_URI, SESSION_CONTROL_METHODS, - SESSION_QUERY_EXTENSION_URI, - SESSION_QUERY_METHODS, + SESSION_MANAGEMENT_EXTENSION_URI, + SESSION_METHODS, STREAMING_EXTENSION_URI, WIRE_CONTRACT_EXTENSION_URI, WORKSPACE_CONTROL_EXTENSION_URI, @@ -61,7 +61,7 @@ from ..execution.executor import OpencodeAgentExecutor from ..invocation import call_with_supported_kwargs from ..jsonrpc.application import ( - OpencodeSessionQueryJSONRPCApplication, + OpencodeSessionManagementJSONRPCApplication, ) from ..opencode_upstream_client import OpencodeUpstreamClient from ..output_modes import normalize_accepted_output_modes @@ -70,7 +70,7 @@ _CHAT_OUTPUT_MODES, _build_agent_card_description, _build_chat_examples, - _build_session_query_skill_examples, + _build_session_management_skill_examples, build_agent_card, build_authenticated_extended_agent_card, ) @@ -129,10 +129,10 @@ "AUTHENTICATED_EXTENDED_CARD_CACHE_CONTROL", "PROVIDER_DISCOVERY_EXTENSION_URI", "PROVIDER_DISCOVERY_METHODS", + "SESSION_MANAGEMENT_EXTENSION_URI", "SESSION_BINDING_EXTENSION_URI", "SESSION_CONTROL_METHODS", - "SESSION_QUERY_EXTENSION_URI", - "SESSION_QUERY_METHODS", + "SESSION_METHODS", "STREAMING_EXTENSION_URI", "WIRE_CONTRACT_EXTENSION_URI", "WORKSPACE_CONTROL_EXTENSION_URI", @@ -142,7 +142,7 @@ "_build_jsonrpc_extension_openapi_description", "_build_jsonrpc_extension_openapi_examples", "_build_rest_message_openapi_examples", - "_build_session_query_skill_examples", + "_build_session_management_skill_examples", "build_authenticated_extended_agent_card", "_configure_logging", "_decode_payload_preview", @@ -559,7 +559,7 @@ def create_app(settings: Settings) -> FastAPI: capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) jsonrpc_methods = { - **capability_snapshot.session_query_methods(), + **capability_snapshot.session_management_methods(), **capability_snapshot.provider_discovery_methods(), **capability_snapshot.workspace_control_methods(), **capability_snapshot.interrupt_recovery_methods(), @@ -567,7 +567,7 @@ def create_app(settings: Settings) -> FastAPI: } # Build JSON-RPC app (POST / by default) and attach REST endpoints (HTTP+JSON) to the same app. - jsonrpc_app = OpencodeSessionQueryJSONRPCApplication( + jsonrpc_app = OpencodeSessionManagementJSONRPCApplication( agent_card=agent_card, extended_agent_card=extended_agent_card, http_handler=handler, diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index 84d41eb..448071c 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -9,8 +9,8 @@ INTERRUPT_CALLBACK_METHODS, INTERRUPT_RECOVERY_METHODS, PROVIDER_DISCOVERY_METHODS, + SESSION_METHODS, SESSION_QUERY_DEFAULT_LIMIT, - SESSION_QUERY_METHODS, WORKSPACE_CONTROL_METHODS, JsonRpcCapabilitySnapshot, build_capability_snapshot, @@ -20,7 +20,7 @@ build_model_selection_extension_params, build_provider_discovery_extension_params, build_session_binding_extension_params, - build_session_query_extension_params, + build_session_management_extension_params, build_streaming_extension_params, build_wire_contract_params, build_workspace_control_extension_params, @@ -33,7 +33,7 @@ def _build_jsonrpc_extension_openapi_description( *, capability_snapshot: JsonRpcCapabilitySnapshot, ) -> str: - session_methods = list(capability_snapshot.session_query_methods().values()) + session_methods = list(capability_snapshot.session_management_methods().values()) provider_methods = ", ".join(sorted(PROVIDER_DISCOVERY_METHODS.values())) workspace_methods = ", ".join(sorted(capability_snapshot.workspace_control_methods().values())) interrupt_recovery_methods = ", ".join(sorted(INTERRUPT_RECOVERY_METHODS.values())) @@ -43,7 +43,7 @@ def _build_jsonrpc_extension_openapi_description( "(message/send, message/stream, tasks/get, tasks/cancel, tasks/resubscribe) " "plus shared model-selection metadata, OpenCode session/provider extensions, " "interrupt recovery extensions, and shared interrupt callback methods.\n\n" - f"OpenCode session lifecycle/query/control methods: {', '.join(session_methods)}.\n" + f"OpenCode session read/mutation/control methods: {', '.join(session_methods)}.\n" "The existing prompt_async extension also accepts provider-private OpenCode " "request.parts[] item types such as text, file, agent, and subtask; subtask " "execution remains upstream runtime behavior rather than a separate A2A " @@ -152,7 +152,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 1, - "method": SESSION_QUERY_METHODS["list_sessions"], + "method": SESSION_METHODS["list_sessions"], "params": { "directory": "services/api", "roots": True, @@ -166,7 +166,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 11, - "method": SESSION_QUERY_METHODS["status"], + "method": SESSION_METHODS["status"], "params": {"directory": "services/api"}, }, }, @@ -175,7 +175,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 12, - "method": SESSION_QUERY_METHODS["get_session"], + "method": SESSION_METHODS["get_session"], "params": {"session_id": "s-1"}, }, }, @@ -184,7 +184,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 13, - "method": SESSION_QUERY_METHODS["get_session_children"], + "method": SESSION_METHODS["get_session_children"], "params": {"session_id": "s-1"}, }, }, @@ -193,7 +193,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 14, - "method": SESSION_QUERY_METHODS["get_session_todo"], + "method": SESSION_METHODS["get_session_todo"], "params": {"session_id": "s-1"}, }, }, @@ -202,7 +202,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 15, - "method": SESSION_QUERY_METHODS["get_session_diff"], + "method": SESSION_METHODS["get_session_diff"], "params": {"session_id": "s-1", "message_id": "msg-1"}, }, }, @@ -211,7 +211,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 2, - "method": SESSION_QUERY_METHODS["get_session_messages"], + "method": SESSION_METHODS["get_session_messages"], "params": { "session_id": "s-1", "before": "cursor-1", @@ -224,7 +224,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 16, - "method": SESSION_QUERY_METHODS["get_session_message"], + "method": SESSION_METHODS["get_session_message"], "params": {"session_id": "s-1", "message_id": "msg-1"}, }, }, @@ -233,7 +233,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 21, - "method": SESSION_QUERY_METHODS["prompt_async"], + "method": SESSION_METHODS["prompt_async"], "params": { "session_id": "s-1", "request": { @@ -247,7 +247,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 211, - "method": SESSION_QUERY_METHODS["prompt_async"], + "method": SESSION_METHODS["prompt_async"], "params": { "session_id": "s-1", "request": { @@ -269,7 +269,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 22, - "method": SESSION_QUERY_METHODS["command"], + "method": SESSION_METHODS["command"], "params": { "session_id": "s-1", "request": { @@ -284,7 +284,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 221, - "method": SESSION_QUERY_METHODS["fork"], + "method": SESSION_METHODS["fork"], "params": { "session_id": "s-1", "request": {"messageID": "msg-1"}, @@ -296,7 +296,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 222, - "method": SESSION_QUERY_METHODS["share"], + "method": SESSION_METHODS["share"], "params": {"session_id": "s-1"}, }, }, @@ -305,7 +305,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 223, - "method": SESSION_QUERY_METHODS["unshare"], + "method": SESSION_METHODS["unshare"], "params": {"session_id": "s-1"}, }, }, @@ -314,7 +314,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 224, - "method": SESSION_QUERY_METHODS["summarize"], + "method": SESSION_METHODS["summarize"], "params": { "session_id": "s-1", "request": { @@ -330,7 +330,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 225, - "method": SESSION_QUERY_METHODS["revert"], + "method": SESSION_METHODS["revert"], "params": { "session_id": "s-1", "request": {"messageID": "msg-1", "partID": "part-1"}, @@ -342,7 +342,7 @@ def _build_jsonrpc_extension_openapi_examples( "value": { "jsonrpc": "2.0", "id": 226, - "method": SESSION_QUERY_METHODS["unrevert"], + "method": SESSION_METHODS["unrevert"], "params": {"session_id": "s-1"}, }, }, @@ -446,13 +446,13 @@ def _build_jsonrpc_extension_openapi_examples( }, }, } - if capability_snapshot.is_method_enabled(SESSION_QUERY_METHODS["shell"]): + if capability_snapshot.is_method_enabled(SESSION_METHODS["shell"]): examples["session_shell"] = { "summary": "Run shell command in an existing session", "value": { "jsonrpc": "2.0", "id": 23, - "method": SESSION_QUERY_METHODS["shell"], + "method": SESSION_METHODS["shell"], "params": { "session_id": "s-1", "request": { @@ -596,7 +596,7 @@ def _patch_jsonrpc_openapi_contract( runtime_profile=runtime_profile, ) streaming = build_streaming_extension_params() - session_query = build_session_query_extension_params( + session_management = build_session_management_extension_params( runtime_profile=runtime_profile, context_id_prefix=SESSION_CONTEXT_PREFIX, ) @@ -646,7 +646,7 @@ def custom_openapi() -> dict[str, Any]: "session_binding": session_binding, "model_selection": model_selection, "streaming": streaming, - "session_query": session_query, + "session_management": session_management, "provider_discovery": provider_discovery, "workspace_control": workspace_control, "interrupt_recovery": interrupt_recovery, diff --git a/src/opencode_a2a/server/request_parsing.py b/src/opencode_a2a/server/request_parsing.py index 35717aa..9913df2 100644 --- a/src/opencode_a2a/server/request_parsing.py +++ b/src/opencode_a2a/server/request_parsing.py @@ -8,7 +8,7 @@ from ..contracts.extensions import ( INTERRUPT_CALLBACK_METHODS, INTERRUPT_RECOVERY_METHODS, - SESSION_QUERY_METHODS, + SESSION_METHODS, WORKSPACE_CONTROL_METHODS, ) from ..jsonrpc.error_responses import build_http_error_body @@ -43,7 +43,7 @@ def _detect_sensitive_extension_method(payload: dict | None) -> str | None: if not isinstance(method, str): return None sensitive_methods = ( - set(SESSION_QUERY_METHODS.values()) + set(SESSION_METHODS.values()) | set(INTERRUPT_CALLBACK_METHODS.values()) | set(INTERRUPT_RECOVERY_METHODS.values()) | set(WORKSPACE_CONTROL_METHODS.values()) diff --git a/tests/contracts/test_extension_contract_consistency.py b/tests/contracts/test_extension_contract_consistency.py index 65e171a..1ccb5cf 100644 --- a/tests/contracts/test_extension_contract_consistency.py +++ b/tests/contracts/test_extension_contract_consistency.py @@ -12,7 +12,7 @@ build_model_selection_extension_params, build_provider_discovery_extension_params, build_session_binding_extension_params, - build_session_query_extension_params, + build_session_management_extension_params, build_streaming_extension_params, build_wire_contract_params, build_workspace_control_extension_params, @@ -27,7 +27,7 @@ MODEL_SELECTION_EXTENSION_URI, PROVIDER_DISCOVERY_EXTENSION_URI, SESSION_BINDING_EXTENSION_URI, - SESSION_QUERY_EXTENSION_URI, + SESSION_MANAGEMENT_EXTENSION_URI, STREAMING_EXTENSION_URI, WIRE_CONTRACT_EXTENSION_URI, WORKSPACE_CONTROL_EXTENSION_URI, @@ -46,7 +46,7 @@ def test_extension_ssot_matches_agent_card_contracts() -> None: session_binding = ext_by_uri[SESSION_BINDING_EXTENSION_URI] model_selection = ext_by_uri[MODEL_SELECTION_EXTENSION_URI] streaming = ext_by_uri[STREAMING_EXTENSION_URI] - session_query = ext_by_uri[SESSION_QUERY_EXTENSION_URI] + session_management = ext_by_uri[SESSION_MANAGEMENT_EXTENSION_URI] provider_discovery = ext_by_uri[PROVIDER_DISCOVERY_EXTENSION_URI] workspace_control = ext_by_uri[WORKSPACE_CONTROL_EXTENSION_URI] interrupt_recovery = ext_by_uri[INTERRUPT_RECOVERY_EXTENSION_URI] @@ -62,7 +62,7 @@ def test_extension_ssot_matches_agent_card_contracts() -> None: runtime_profile=runtime_profile, ) expected_streaming = build_streaming_extension_params() - expected_session_query = build_session_query_extension_params( + expected_session_management = build_session_management_extension_params( runtime_profile=runtime_profile, context_id_prefix=SESSION_CONTEXT_PREFIX, ) @@ -75,8 +75,8 @@ def test_extension_ssot_matches_agent_card_contracts() -> None: expected_interrupt_recovery = build_interrupt_recovery_extension_params( runtime_profile=runtime_profile, ) - assert expected_session_query["pagination"]["default_limit"] == SESSION_QUERY_DEFAULT_LIMIT - assert expected_session_query["pagination"]["max_limit"] == SESSION_QUERY_MAX_LIMIT + assert expected_session_management["pagination"]["default_limit"] == SESSION_QUERY_DEFAULT_LIMIT + assert expected_session_management["pagination"]["max_limit"] == SESSION_QUERY_MAX_LIMIT expected_interrupt_callback = build_interrupt_callback_extension_params( runtime_profile=runtime_profile, ) @@ -102,8 +102,8 @@ def test_extension_ssot_matches_agent_card_contracts() -> None: assert streaming.params == expected_streaming, ( "Streaming extension drifted from contracts.extensions SSOT." ) - assert session_query.params == expected_session_query, ( - "Session query extension drifted from contracts.extensions SSOT." + assert session_management.params == expected_session_management, ( + "Session management extension drifted from contracts.extensions SSOT." ) assert provider_discovery.params == expected_provider_discovery, ( "Provider discovery extension drifted from contracts.extensions SSOT." @@ -142,7 +142,7 @@ def test_openapi_jsonrpc_contract_extension_matches_ssot() -> None: session_binding = contract["session_binding"] model_selection = contract["model_selection"] streaming = contract["streaming"] - session_query = contract["session_query"] + session_management = contract["session_management"] provider_discovery = contract["provider_discovery"] workspace_control = contract["workspace_control"] interrupt_recovery = contract["interrupt_recovery"] @@ -158,7 +158,7 @@ def test_openapi_jsonrpc_contract_extension_matches_ssot() -> None: runtime_profile=runtime_profile, ) expected_streaming = build_streaming_extension_params() - expected_session_query = build_session_query_extension_params( + expected_session_management = build_session_management_extension_params( runtime_profile=runtime_profile, context_id_prefix=SESSION_CONTEXT_PREFIX, ) @@ -196,8 +196,8 @@ def test_openapi_jsonrpc_contract_extension_matches_ssot() -> None: assert streaming == expected_streaming, ( "OpenAPI streaming contract drifted from contracts.extensions SSOT." ) - assert session_query == expected_session_query, ( - "OpenAPI session query contract drifted from contracts.extensions SSOT." + assert session_management == expected_session_management, ( + "OpenAPI session management contract drifted from contracts.extensions SSOT." ) assert provider_discovery == expected_provider_discovery, ( "OpenAPI provider discovery contract drifted from contracts.extensions SSOT." @@ -238,7 +238,7 @@ def test_openapi_jsonrpc_contract_extension_matches_ssot() -> None: example_methods = { value.get("value", {}).get("method") for value in example_values if isinstance(value, dict) } - expected_methods = set(session_query["methods"].values()) | set( + expected_methods = set(session_management["methods"].values()) | set( INTERRUPT_CALLBACK_METHODS.values() ) expected_methods |= { diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index 02f6348..0561b9c 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -152,7 +152,8 @@ async def test_unsupported_method_notification_returns_204() -> None: ) # Even unsupported methods follow notification semantics: if id is missing, return 204. - # Note: OpencodeSessionQueryJSONRPCApplication._handle_requests returns 204 for notifications + # Note: OpencodeSessionManagementJSONRPCApplication._handle_requests + # returns 204 for notifications # if it catches the method. For unsupported methods, it now also returns 204 if id is None. assert response.status_code == 204 diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 8f1da33..4ef29f3 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -15,7 +15,7 @@ MODEL_SELECTION_EXTENSION_URI, PROVIDER_DISCOVERY_EXTENSION_URI, SESSION_BINDING_EXTENSION_URI, - SESSION_QUERY_EXTENSION_URI, + SESSION_MANAGEMENT_EXTENSION_URI, STREAMING_EXTENSION_URI, WIRE_CONTRACT_EXTENSION_URI, WORKSPACE_CONTROL_EXTENSION_URI, @@ -43,8 +43,8 @@ def test_agent_card_description_reflects_actual_transport_capabilities() -> None assert card.security == [{"bearerAuth": []}] assert skills_by_id["opencode.chat"].input_modes == ["text/plain", "application/octet-stream"] assert skills_by_id["opencode.chat"].output_modes == ["text/plain", "application/json"] - assert skills_by_id["opencode.sessions.query"].input_modes == ["application/json"] - assert skills_by_id["opencode.sessions.query"].output_modes == ["application/json"] + assert skills_by_id["opencode.sessions.management"].input_modes == ["application/json"] + assert skills_by_id["opencode.sessions.management"].output_modes == ["application/json"] assert skills_by_id["opencode.interrupt.callback"].input_modes == ["application/json"] assert skills_by_id["opencode.interrupt.callback"].output_modes == ["application/json"] @@ -131,7 +131,7 @@ def test_public_agent_card_is_slimmed_but_keeps_core_shared_contract_hints() -> } for uri in ( - SESSION_QUERY_EXTENSION_URI, + SESSION_MANAGEMENT_EXTENSION_URI, PROVIDER_DISCOVERY_EXTENSION_URI, WORKSPACE_CONTROL_EXTENSION_URI, INTERRUPT_RECOVERY_EXTENSION_URI, @@ -299,19 +299,23 @@ def test_agent_card_injects_profile_into_extensions() -> None: }, } - session_query = ext_by_uri[SESSION_QUERY_EXTENSION_URI] - assert session_query.params["profile"]["runtime_context"]["project"] == "alpha" - assert session_query.params["control_methods"] == { + session_management = ext_by_uri[SESSION_MANAGEMENT_EXTENSION_URI] + assert session_management.params["profile"]["runtime_context"]["project"] == "alpha" + assert session_management.params["control_methods"] == { "prompt_async": "opencode.sessions.prompt_async", "command": "opencode.sessions.command", } - assert session_query.params["lifecycle_methods"] == { + assert session_management.params["read_methods"] == { "status": "opencode.sessions.status", + "list_sessions": "opencode.sessions.list", "get_session": "opencode.sessions.get", "get_session_children": "opencode.sessions.children", "get_session_todo": "opencode.sessions.todo", "get_session_diff": "opencode.sessions.diff", "get_session_message": "opencode.sessions.messages.get", + "get_session_messages": "opencode.sessions.messages.list", + } + assert session_management.params["mutation_methods"] == { "fork": "opencode.sessions.fork", "share": "opencode.sessions.share", "unshare": "opencode.sessions.unshare", @@ -319,40 +323,46 @@ def test_agent_card_injects_profile_into_extensions() -> None: "revert": "opencode.sessions.revert", "unrevert": "opencode.sessions.unrevert", } - assert session_query.params["methods"]["status"] == "opencode.sessions.status" - assert session_query.params["methods"]["get_session"] == "opencode.sessions.get" - assert session_query.params["methods"]["prompt_async"] == "opencode.sessions.prompt_async" - assert session_query.params["methods"]["command"] == "opencode.sessions.command" - assert "shell" not in session_query.params["methods"] - assert session_query.params["control_method_flags"]["opencode.sessions.shell"] == { + assert session_management.params["methods"]["status"] == "opencode.sessions.status" + assert session_management.params["methods"]["get_session"] == "opencode.sessions.get" + assert session_management.params["methods"]["prompt_async"] == "opencode.sessions.prompt_async" + assert session_management.params["methods"]["command"] == "opencode.sessions.command" + assert "shell" not in session_management.params["methods"] + assert session_management.params["control_method_flags"]["opencode.sessions.shell"] == { "enabled_by_default": False, "config_key": "A2A_ENABLE_SESSION_SHELL", } - assert session_query.params["pagination"]["default_limit"] == SESSION_QUERY_DEFAULT_LIMIT - assert session_query.params["pagination"]["max_limit"] == SESSION_QUERY_MAX_LIMIT - assert session_query.params["pagination"]["cursor_param"] == "before" - assert session_query.params["pagination"]["result_cursor_field"] == "next_cursor" - assert session_query.params["pagination"]["applies_to"] == [ + assert session_management.params["pagination"]["default_limit"] == SESSION_QUERY_DEFAULT_LIMIT + assert session_management.params["pagination"]["max_limit"] == SESSION_QUERY_MAX_LIMIT + assert session_management.params["pagination"]["cursor_param"] == "before" + assert session_management.params["pagination"]["result_cursor_field"] == "next_cursor" + assert session_management.params["pagination"]["applies_to"] == [ "opencode.sessions.list", "opencode.sessions.messages.list", ] - assert session_query.params["pagination"]["cursor_applies_to"] == [ + assert session_management.params["pagination"]["cursor_applies_to"] == [ "opencode.sessions.messages.list" ] - prompt_contract = session_query.params["method_contracts"]["opencode.sessions.prompt_async"] - command_contract = session_query.params["method_contracts"]["opencode.sessions.command"] - status_contract = session_query.params["method_contracts"]["opencode.sessions.status"] - get_contract = session_query.params["method_contracts"]["opencode.sessions.get"] - diff_contract = session_query.params["method_contracts"]["opencode.sessions.diff"] - message_get_contract = session_query.params["method_contracts"][ + prompt_contract = session_management.params["method_contracts"][ + "opencode.sessions.prompt_async" + ] + command_contract = session_management.params["method_contracts"]["opencode.sessions.command"] + status_contract = session_management.params["method_contracts"]["opencode.sessions.status"] + get_contract = session_management.params["method_contracts"]["opencode.sessions.get"] + diff_contract = session_management.params["method_contracts"]["opencode.sessions.diff"] + message_get_contract = session_management.params["method_contracts"][ "opencode.sessions.messages.get" ] - fork_contract = session_query.params["method_contracts"]["opencode.sessions.fork"] - summarize_contract = session_query.params["method_contracts"]["opencode.sessions.summarize"] - revert_contract = session_query.params["method_contracts"]["opencode.sessions.revert"] - unrevert_contract = session_query.params["method_contracts"]["opencode.sessions.unrevert"] - list_contract = session_query.params["method_contracts"]["opencode.sessions.list"] - messages_contract = session_query.params["method_contracts"]["opencode.sessions.messages.list"] + fork_contract = session_management.params["method_contracts"]["opencode.sessions.fork"] + summarize_contract = session_management.params["method_contracts"][ + "opencode.sessions.summarize" + ] + revert_contract = session_management.params["method_contracts"]["opencode.sessions.revert"] + unrevert_contract = session_management.params["method_contracts"]["opencode.sessions.unrevert"] + list_contract = session_management.params["method_contracts"]["opencode.sessions.list"] + messages_contract = session_management.params["method_contracts"][ + "opencode.sessions.messages.list" + ] assert status_contract["result"]["fields"] == ["items"] assert get_contract["params"]["required"] == ["session_id"] assert get_contract["result"]["fields"] == ["item"] @@ -457,30 +467,31 @@ def test_agent_card_injects_profile_into_extensions() -> None: assert revert_contract["notification_response_status"] == 204 assert unrevert_contract["notification_response_status"] == 204 assert prompt_contract["notification_response_status"] == 204 - assert "result_envelope" not in session_query.params - assert "opencode.sessions.shell" not in session_query.params["method_contracts"] + assert "result_envelope" not in session_management.params + assert "opencode.sessions.shell" not in session_management.params["method_contracts"] assert ( - session_query.params["context_semantics"]["a2a_context_id_prefix"] == SESSION_CONTEXT_PREFIX + session_management.params["context_semantics"]["a2a_context_id_prefix"] + == SESSION_CONTEXT_PREFIX ) assert ( - session_query.params["context_semantics"]["upstream_session_id_field"] + session_management.params["context_semantics"]["upstream_session_id_field"] == "metadata.shared.session.id" ) - assert session_query.params["errors"]["business_codes"] == { + assert session_management.params["errors"]["business_codes"] == { "SESSION_NOT_FOUND": -32001, "SESSION_FORBIDDEN": -32006, "UPSTREAM_UNREACHABLE": -32002, "UPSTREAM_HTTP_ERROR": -32003, "UPSTREAM_PAYLOAD_ERROR": -32005, } - assert session_query.params["errors"]["error_data_fields"] == [ + assert session_management.params["errors"]["error_data_fields"] == [ "type", "method", "session_id", "upstream_status", "detail", ] - assert session_query.params["errors"]["invalid_params_data_fields"] == [ + assert session_management.params["errors"]["invalid_params_data_fields"] == [ "type", "field", "fields", @@ -730,10 +741,10 @@ def test_agent_card_contracts_include_shell_when_enabled() -> None: ) ext_by_uri = {ext.uri: ext for ext in card.capabilities.extensions or []} - session_query = ext_by_uri[SESSION_QUERY_EXTENSION_URI] - assert session_query.params["control_methods"]["shell"] == "opencode.sessions.shell" - assert session_query.params["methods"]["shell"] == "opencode.sessions.shell" - assert "opencode.sessions.shell" in session_query.params["method_contracts"] + session_management = ext_by_uri[SESSION_MANAGEMENT_EXTENSION_URI] + assert session_management.params["control_methods"]["shell"] == "opencode.sessions.shell" + assert session_management.params["methods"]["shell"] == "opencode.sessions.shell" + assert "opencode.sessions.shell" in session_management.params["method_contracts"] compatibility = ext_by_uri[COMPATIBILITY_PROFILE_EXTENSION_URI] shell_policy = compatibility.params["method_retention"]["opencode.sessions.shell"] @@ -768,7 +779,9 @@ def test_agent_card_contracts_include_shell_when_enabled() -> None: }, } - session_skill = next(skill for skill in card.skills if skill.id == "opencode.sessions.query") + session_skill = next( + skill for skill in card.skills if skill.id == "opencode.sessions.management" + ) assert any("opencode.sessions.shell" in example for example in session_skill.examples) @@ -805,7 +818,9 @@ def test_agent_card_contracts_include_workspace_mutations_when_enabled() -> None def test_agent_card_skills_hide_shell_when_disabled_by_default() -> None: card = build_agent_card(make_settings(a2a_bearer_token="test-token")) - session_skill = next(skill for skill in card.skills if skill.id == "opencode.sessions.query") + session_skill = next( + skill for skill in card.skills if skill.id == "opencode.sessions.management" + ) provider_skill = next(skill for skill in card.skills if skill.id == "opencode.providers.query") workspace_skill = next( skill for skill in card.skills if skill.id == "opencode.workspace.control" @@ -834,9 +849,9 @@ def test_agent_card_hides_shell_when_policy_disables_it() -> None: ) ext_by_uri = {ext.uri: ext for ext in card.capabilities.extensions or []} - session_query = ext_by_uri[SESSION_QUERY_EXTENSION_URI] + session_management = ext_by_uri[SESSION_MANAGEMENT_EXTENSION_URI] compatibility = ext_by_uri[COMPATIBILITY_PROFILE_EXTENSION_URI] - assert "shell" not in session_query.params["methods"] - assert "opencode.sessions.shell" not in session_query.params["method_contracts"] + assert "shell" not in session_management.params["methods"] + assert "opencode.sessions.shell" not in session_management.params["method_contracts"] assert compatibility.params["runtime_features"]["session_shell"]["availability"] == "disabled" diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index 53cbd08..fbee4cc 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -31,7 +31,7 @@ _build_jsonrpc_extension_openapi_description, _build_jsonrpc_extension_openapi_examples, _build_rest_message_openapi_examples, - _build_session_query_skill_examples, + _build_session_management_skill_examples, _configure_logging, _decode_payload_preview, _detect_sensitive_extension_method, @@ -89,10 +89,8 @@ def test_request_payload_helpers_cover_edge_cases() -> None: assert _detect_sensitive_extension_method(None) is None assert _detect_sensitive_extension_method({"method": "message/send"}) is None assert ( - _detect_sensitive_extension_method( - {"method": app_module.SESSION_QUERY_METHODS["list_sessions"]} - ) - == app_module.SESSION_QUERY_METHODS["list_sessions"] + _detect_sensitive_extension_method({"method": app_module.SESSION_METHODS["list_sessions"]}) + == app_module.SESSION_METHODS["list_sessions"] ) assert _parse_content_length(None) is None @@ -250,13 +248,15 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: assert any("project alpha" in item for item in _build_chat_examples("alpha")) assert all( "shell" not in item - for item in _build_session_query_skill_examples( + for item in _build_session_management_skill_examples( capability_snapshot=disabled_capability_snapshot ) ) assert any( "shell" in item - for item in _build_session_query_skill_examples(capability_snapshot=capability_snapshot) + for item in _build_session_management_skill_examples( + capability_snapshot=capability_snapshot + ) ) assert "opencode.sessions.shell" in _build_jsonrpc_extension_openapi_description( capability_snapshot=capability_snapshot diff --git a/tests/server/test_transport_contract.py b/tests/server/test_transport_contract.py index 36424a1..2ac9eba 100644 --- a/tests/server/test_transport_contract.py +++ b/tests/server/test_transport_contract.py @@ -21,7 +21,7 @@ from opencode_a2a.server.application import ( AUTHENTICATED_EXTENDED_CARD_CACHE_CONTROL, PUBLIC_AGENT_CARD_CACHE_CONTROL, - SESSION_QUERY_EXTENSION_URI, + SESSION_MANAGEMENT_EXTENSION_URI, _normalize_log_level, build_agent_card, create_app, @@ -478,10 +478,10 @@ async def test_agent_card_routes_split_public_and_authenticated_extended_contrac extended_extensions = { item["uri"]: item for item in extended_card.json()["capabilities"]["extensions"] } - assert public_extensions[SESSION_QUERY_EXTENSION_URI].get("params") is None - assert extended_extensions[SESSION_QUERY_EXTENSION_URI]["params"]["methods"]["status"] == ( - "opencode.sessions.status" - ) + assert public_extensions[SESSION_MANAGEMENT_EXTENSION_URI].get("params") is None + assert extended_extensions[SESSION_MANAGEMENT_EXTENSION_URI]["params"]["methods"][ + "status" + ] == ("opencode.sessions.status") assert len(public_card.content) < len(extended_card.content) rpc_card = await client.post( @@ -497,7 +497,7 @@ async def test_agent_card_routes_split_public_and_authenticated_extended_contrac assert rpc_card.status_code == 200 assert ( rpc_card.json()["result"]["capabilities"]["extensions"][3]["uri"] - == SESSION_QUERY_EXTENSION_URI + == SESSION_MANAGEMENT_EXTENSION_URI ) From 4d46e7021432ab77175305eb5980a9b2f68c958a Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Mon, 6 Apr 2026 22:47:00 -0400 Subject: [PATCH 5/5] fix(contracts): clarify workspace and interrupt extension boundaries (#393) --- docs/extension-specifications.md | 6 +- docs/guide.md | 2 + src/opencode_a2a/contracts/extensions.py | 76 +++++++++++++++++++++++- src/opencode_a2a/server/agent_card.py | 33 +++++----- tests/server/test_agent_card.py | 52 ++++++++++++++++ tests/server/test_transport_contract.py | 5 +- 6 files changed, 154 insertions(+), 20 deletions(-) diff --git a/docs/extension-specifications.md b/docs/extension-specifications.md index afce7fb..17a4555 100644 --- a/docs/extension-specifications.md +++ b/docs/extension-specifications.md @@ -72,16 +72,16 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens - Scope: provider-private recovery methods for pending local interrupt bindings - Public Agent Card: capability declaration only -- Authenticated extended card: full method contracts, error surface, and local-registry notes +- Authenticated extended card: full method contracts, error surface, local-registry notes, and identity-scope semantics - Transport: A2A JSON-RPC extension methods ## OpenCode Workspace Control v1 URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extension-specifications.md#opencode-workspace-control-v1` -- Scope: provider-private project/workspace/worktree discovery plus deployment-conditional operator mutation methods +- Scope: provider-private project discovery plus workspace/worktree surfaces over upstream experimental endpoints, with deployment-conditional operator mutation methods - Public Agent Card: capability declaration only -- Authenticated extended card: full method contracts, error surface, and routing notes +- Authenticated extended card: full method contracts, error surface, routing notes, and upstream-stability hints - Transport: A2A JSON-RPC extension methods ## A2A Compatibility Profile v1 diff --git a/docs/guide.md b/docs/guide.md index b9b3557..6e66684 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -992,6 +992,7 @@ Behavior notes: - These methods target the active OpenCode deployment project. They are not routed through per-request workspace forwarding. - `metadata.opencode.workspace.id` is declared consistently across the adapter, but current workspace-control methods do not use it to change the target project. +- `opencode.workspaces.*` and `opencode.worktrees.*` currently wrap upstream `/experimental/workspace` and `/experimental/worktree` endpoints; treat them as experimental-upstream surfaces even when declared by the adapter. - Mutating methods should be treated as operator-only control-plane actions and are disabled by default. ### Project Discovery (`opencode.projects.list`, `opencode.projects.current`) @@ -1118,6 +1119,7 @@ Response shape: Notes: - Recovery results are scoped to the current authenticated caller identity when the runtime can resolve one. +- If the runtime cannot resolve a caller identity for the current request, recovery queries return an empty item list. - The runtime stores normalized interrupt `details` alongside request bindings, so recovery results match the shape emitted in `metadata.shared.interrupt.details`. - The first implementation stage reads from the local interrupt registry rather than proxying upstream global `/permission` or `/question` pending lists. - Use recovery queries to rediscover pending requests after reconnecting; use `a2a.interrupt.*` methods to resolve them. diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index c9c4bf6..c94b995 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -600,6 +600,22 @@ class WorkspaceControlMethodContract: WORKSPACE_DISCOVERY_METHODS: dict[str, str] = { key: WORKSPACE_CONTROL_METHODS[key] for key in WORKSPACE_DISCOVERY_METHOD_KEYS } +WORKSPACE_STABLE_METHOD_KEYS: tuple[str, ...] = ("list_projects", "get_current_project") +WORKSPACE_STABLE_METHODS: dict[str, str] = { + key: WORKSPACE_CONTROL_METHODS[key] for key in WORKSPACE_STABLE_METHOD_KEYS +} +WORKSPACE_EXPERIMENTAL_UPSTREAM_METHOD_KEYS: tuple[str, ...] = ( + "list_workspaces", + "list_worktrees", + "create_workspace", + "remove_workspace", + "create_worktree", + "remove_worktree", + "reset_worktree", +) +WORKSPACE_EXPERIMENTAL_UPSTREAM_METHODS: dict[str, str] = { + key: WORKSPACE_CONTROL_METHODS[key] for key in WORKSPACE_EXPERIMENTAL_UPSTREAM_METHOD_KEYS +} WORKSPACE_MUTATION_METHOD_KEYS: tuple[str, ...] = ( "create_workspace", "remove_workspace", @@ -1182,6 +1198,11 @@ def build_interrupt_recovery_extension_params( "method_contracts": method_contracts, "supported_metadata": [], "provider_private_metadata": [], + "recovery_scope": { + "data_source": "local_interrupt_binding_registry", + "identity_scope": "current_authenticated_caller", + "empty_result_when_identity_unavailable": True, + }, "item_fields": { "request_id": "items[].request_id", "session_id": "items[].session_id", @@ -1204,6 +1225,10 @@ def build_interrupt_recovery_extension_params( "Results are scoped to the current authenticated caller identity when the " "runtime can resolve one." ), + ( + "If the runtime cannot resolve a caller identity for the current request, " + "recovery queries return an empty item list." + ), ( "Use a2a.interrupt.* methods to resolve requests; opencode.permissions.list " "and opencode.questions.list are recovery surfaces only." @@ -1325,6 +1350,17 @@ def build_workspace_control_extension_params( "methods": methods, "control_method_flags": capability_snapshot.workspace_mutation_method_flags(), "method_contracts": method_contracts, + "upstream_stability": { + WORKSPACE_CONTROL_METHODS["list_projects"]: "stable", + WORKSPACE_CONTROL_METHODS["get_current_project"]: "stable", + WORKSPACE_CONTROL_METHODS["list_workspaces"]: "experimental", + WORKSPACE_CONTROL_METHODS["list_worktrees"]: "experimental", + WORKSPACE_CONTROL_METHODS["create_workspace"]: "experimental", + WORKSPACE_CONTROL_METHODS["remove_workspace"]: "experimental", + WORKSPACE_CONTROL_METHODS["create_worktree"]: "experimental", + WORKSPACE_CONTROL_METHODS["remove_worktree"]: "experimental", + WORKSPACE_CONTROL_METHODS["reset_worktree"]: "experimental", + }, "supported_metadata": ["opencode.workspace.id", "opencode.directory"], "provider_private_metadata": ["opencode.workspace.id", "opencode.directory"], "routing_fields": { @@ -1351,6 +1387,10 @@ def build_workspace_control_extension_params( "control-plane methods operate on the active deployment project rather than " "per-request workspace forwarding." ), + ( + "Workspace/worktree discovery and mutation methods currently wrap upstream " + "/experimental/workspace and /experimental/worktree endpoints." + ), ], } @@ -1412,7 +1452,21 @@ def build_compatibility_profile_params( "retention": "stable", "extension_uri": WORKSPACE_CONTROL_EXTENSION_URI, } - for method in WORKSPACE_DISCOVERY_METHODS.values() + for method in WORKSPACE_STABLE_METHODS.values() + } + ) + method_retention.update( + { + method: { + "surface": "extension", + "availability": "always", + "retention": "experimental-upstream", + "extension_uri": WORKSPACE_CONTROL_EXTENSION_URI, + } + for method in ( + WORKSPACE_EXPERIMENTAL_UPSTREAM_METHODS["list_workspaces"], + WORKSPACE_EXPERIMENTAL_UPSTREAM_METHODS["list_worktrees"], + ) } ) method_retention.update( @@ -1422,10 +1476,16 @@ def build_compatibility_profile_params( "availability": "always", "retention": "stable", "extension_uri": INTERRUPT_RECOVERY_EXTENSION_URI, + "implementation_scope": "adapter-local", + "identity_scope": "current_authenticated_caller", } for method in INTERRUPT_RECOVERY_METHODS.values() } ) + for method in WORKSPACE_MUTATION_METHODS.values(): + retention = method_retention.get(method) + if retention is not None: + retention["upstream_stability"] = "experimental" method_retention.update( { method: { @@ -1475,12 +1535,15 @@ def build_compatibility_profile_params( WORKSPACE_CONTROL_EXTENSION_URI: { "surface": "jsonrpc-extension", "availability": "always", - "retention": "stable", + "retention": "mixed", + "upstream_stability": "mixed", }, INTERRUPT_RECOVERY_EXTENSION_URI: { "surface": "jsonrpc-extension", "availability": "always", "retention": "stable", + "implementation_scope": "adapter-local", + "identity_scope": "current_authenticated_caller", }, INTERRUPT_CALLBACK_EXTENSION_URI: { "surface": "jsonrpc-extension", @@ -1520,6 +1583,15 @@ def build_compatibility_profile_params( "reset as deployment-conditional operator surfaces rather than baseline " "workspace discovery methods." ), + ( + "Treat opencode.workspaces.list and opencode.worktrees.list as declared " + "adapter contracts over upstream experimental endpoints, not the same " + "stability tier as project discovery." + ), + ( + "Treat opencode.permissions.list and opencode.questions.list as adapter-local, " + "identity-scoped recovery views rather than upstream global pending queues." + ), ( "Treat declared service behaviors as stable server-level semantic " "enhancements layered on top of the core A2A method baseline." diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index dd564ff..f886e35 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -327,9 +327,10 @@ def _build_agent_extensions( uri=WORKSPACE_CONTROL_EXTENSION_URI, required=False, description=( - "Expose OpenCode-specific project/workspace/worktree discovery methods " - "plus deployment-conditional control-plane mutations through JSON-RPC " - "extensions." + "Expose OpenCode-specific project discovery plus workspace/worktree " + "discovery and deployment-conditional control-plane mutations through " + "JSON-RPC extensions. Workspace/worktree surfaces depend on upstream " + "experimental endpoints." ), params=workspace_control_extension_params if include_detailed_contracts else None, ), @@ -337,8 +338,9 @@ def _build_agent_extensions( uri=INTERRUPT_RECOVERY_EXTENSION_URI, required=False, description=( - "Expose provider-private interrupt recovery methods so clients can " - "list pending permission/question requests after reconnecting." + "Expose adapter-local, identity-scoped interrupt recovery methods so " + "clients can rediscover pending permission/question requests after " + "reconnecting." ), params=interrupt_recovery_extension_params if include_detailed_contracts else None, ), @@ -428,8 +430,10 @@ def _build_agent_skills( id="opencode.workspace.control", name="OpenCode Workspace Control", description=( - "Manage OpenCode projects, workspaces, and worktrees through " - "provider-private JSON-RPC extensions." + "Discover OpenCode projects, workspaces, and worktrees through " + "provider-private JSON-RPC extensions. Mutation methods are " + "deployment-conditional and workspace/worktree surfaces depend on " + "upstream experimental endpoints." ), input_modes=list(_JSON_RPC_MODES), output_modes=list(_JSON_RPC_MODES), @@ -439,8 +443,8 @@ def _build_agent_skills( id="opencode.interrupt.recovery", name="OpenCode Interrupt Recovery", description=( - "Recover pending permission and question interrupts through " - "provider-private JSON-RPC extensions." + "Recover pending permission and question interrupts from the " + "adapter-local interrupt registry for the current caller." ), input_modes=list(_JSON_RPC_MODES), output_modes=list(_JSON_RPC_MODES), @@ -508,9 +512,10 @@ def _build_agent_skills( id="opencode.workspace.control", name="OpenCode Workspace Control", description=( - "provider-private OpenCode project/workspace/worktree discovery surface " - "with deployment-conditional mutation methods exposed through JSON-RPC " - "extensions." + "provider-private OpenCode project discovery plus workspace/worktree " + "discovery surface with deployment-conditional mutation methods exposed " + "through JSON-RPC extensions; workspace/worktree methods currently wrap " + "upstream experimental endpoints." ), input_modes=list(_JSON_RPC_MODES), output_modes=list(_JSON_RPC_MODES), @@ -523,8 +528,8 @@ def _build_agent_skills( id="opencode.interrupt.recovery", name="OpenCode Interrupt Recovery", description=( - "provider-private OpenCode interrupt recovery surface exposed through " - "JSON-RPC extensions." + "adapter-local, identity-scoped interrupt recovery surface exposed " + "through JSON-RPC extensions." ), input_modes=list(_JSON_RPC_MODES), output_modes=list(_JSON_RPC_MODES), diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 4ef29f3..7ccb037 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -539,6 +539,17 @@ def test_agent_card_injects_profile_into_extensions() -> None: "enabled_by_default": False, "config_key": "A2A_ENABLE_WORKSPACE_MUTATIONS", } + assert workspace_control.params["upstream_stability"] == { + "opencode.projects.list": "stable", + "opencode.projects.current": "stable", + "opencode.workspaces.list": "experimental", + "opencode.worktrees.list": "experimental", + "opencode.workspaces.create": "experimental", + "opencode.workspaces.remove": "experimental", + "opencode.worktrees.create": "experimental", + "opencode.worktrees.remove": "experimental", + "opencode.worktrees.reset": "experimental", + } assert workspace_control.params["routing_fields"]["workspace_id"] == ( "metadata.opencode.workspace.id" ) @@ -559,6 +570,11 @@ def test_agent_card_injects_profile_into_extensions() -> None: "fields": ["items"], "items_type": "InterruptRequest[]", } + assert interrupt_recovery.params["recovery_scope"] == { + "data_source": "local_interrupt_binding_registry", + "identity_scope": "current_authenticated_caller", + "empty_result_when_identity_unavailable": True, + } assert interrupt_recovery.params["item_fields"]["details"] == "items[].details" assert interrupt_recovery.params["errors"]["invalid_params_data_fields"] == [ "type", @@ -632,10 +648,27 @@ def test_agent_card_injects_profile_into_extensions() -> None: "availability": "always", "retention": "stable", } + assert compatibility.params["extension_retention"][WORKSPACE_CONTROL_EXTENSION_URI] == { + "surface": "jsonrpc-extension", + "availability": "always", + "retention": "mixed", + "upstream_stability": "mixed", + } + assert compatibility.params["extension_retention"][INTERRUPT_RECOVERY_EXTENSION_URI] == { + "surface": "jsonrpc-extension", + "availability": "always", + "retention": "stable", + "implementation_scope": "adapter-local", + "identity_scope": "current_authenticated_caller", + } shell_policy = compatibility.params["method_retention"]["opencode.sessions.shell"] + workspace_list_policy = compatibility.params["method_retention"]["opencode.workspaces.list"] workspace_mutation_policy = compatibility.params["method_retention"][ "opencode.workspaces.create" ] + interrupt_recovery_policy = compatibility.params["method_retention"][ + "opencode.permissions.list" + ] assert compatibility.params["deployment"]["id"] == "single_tenant_shared_workspace" assert compatibility.params["runtime_features"]["session_shell"]["availability"] == "disabled" assert compatibility.params["runtime_features"]["workspace_mutations"]["availability"] == ( @@ -644,9 +677,24 @@ def test_agent_card_injects_profile_into_extensions() -> None: assert shell_policy["availability"] == "disabled" assert shell_policy["retention"] == "deployment-conditional" assert shell_policy["toggle"] == "A2A_ENABLE_SESSION_SHELL" + assert workspace_list_policy == { + "surface": "extension", + "availability": "always", + "retention": "experimental-upstream", + "extension_uri": WORKSPACE_CONTROL_EXTENSION_URI, + } assert workspace_mutation_policy["availability"] == "disabled" assert workspace_mutation_policy["retention"] == "deployment-conditional" assert workspace_mutation_policy["toggle"] == "A2A_ENABLE_WORKSPACE_MUTATIONS" + assert workspace_mutation_policy["upstream_stability"] == "experimental" + assert interrupt_recovery_policy == { + "surface": "extension", + "availability": "always", + "retention": "stable", + "extension_uri": INTERRUPT_RECOVERY_EXTENSION_URI, + "implementation_scope": "adapter-local", + "identity_scope": "current_authenticated_caller", + } assert compatibility.params["method_retention"]["agent/getAuthenticatedExtendedCard"] == { "surface": "core", "availability": "always", @@ -796,6 +844,10 @@ def test_agent_card_contracts_include_workspace_mutations_when_enabled() -> None assert workspace_control.params["methods"]["create_worktree"] == "opencode.worktrees.create" assert "opencode.workspaces.create" in workspace_control.params["method_contracts"] assert "opencode.worktrees.reset" in workspace_control.params["method_contracts"] + assert ( + workspace_control.params["upstream_stability"]["opencode.workspaces.create"] + == "experimental" + ) compatibility = ext_by_uri[COMPATIBILITY_PROFILE_EXTENSION_URI] workspace_mutation_policy = compatibility.params["method_retention"][ diff --git a/tests/server/test_transport_contract.py b/tests/server/test_transport_contract.py index 2ac9eba..11a2e75 100644 --- a/tests/server/test_transport_contract.py +++ b/tests/server/test_transport_contract.py @@ -609,7 +609,10 @@ async def test_global_http_gzip_applies_to_eligible_non_streaming_responses(monk assert extended_response.status_code == 200 assert extended_response.headers.get("content-encoding") == "gzip" assert public_response.status_code == 200 - assert public_response.headers.get("content-encoding") is None + if len(public_response.content) >= 2048: + assert public_response.headers.get("content-encoding") == "gzip" + else: + assert public_response.headers.get("content-encoding") is None assert health_response.status_code == 200 assert health_response.headers.get("content-encoding") is None