Skip to content
Draft
45 changes: 44 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,53 @@ This document defines the compatibility promises `opencode-a2a` currently uphold
## Runtime Support

- Python versions: 3.11, 3.12, 3.13, 3.14
- A2A SDK line: `1.x.y`
- A2A Python SDK: `a2a-sdk==1.2.3` (SDK package version; not an OpenCode API version)
- Supported A2A protocol line: `1.0`
- OpenCode runtime line: `1.18.x` (verified with `1.18.19`)

OpenCode V2 is an opt-in, experimental session-transport slice selected with
`OPENCODE_API_VERSION=v2`. It implements `/api/info`, `/api/location`, and
session create/get/list transport only; A2A prompt execution and all
OpenCode-specific JSON-RPC extensions remain unavailable. This remains an
explicit safety gate, not a claim that V2 prompt execution works.

The V2 selector is **OpenCode's upstream HTTP API version**, not "A2A SDK V2".
The runtime continues to expose A2A protocol 1.0 interfaces. Its V2 Agent Card
contains no extension declarations and retains only an informational execution
status skill. The A2A Python SDK card contract used here requires populated
default input/output mode fields and a skill entry; those required card fields
are not a claim that V2 accepts A2A messages. `SendMessage` enters the SDK task
lifecycle and returns a failed task with the fail-closed explanation before
session creation or prompt submission. `SendStreamingMessage` is rejected as
unsupported because the card declares `streaming=false`. Neither path makes an
upstream HTTP request. Direct
`opencode.*` JSON-RPC calls return `-32601 Method not found` without an upstream
HTTP request. The same extension hiding applies to shared interrupt callbacks.

| Capability family | OpenCode API V1 (`1.18.x`, verified `1.18.19`) | OpenCode API V2 (`2.0.28` session-transport exercise only) |
| --- | --- | --- |
| A2A protocol interfaces | HTTP+JSON and JSON-RPC, protocol `1.0`; current core behavior | Interfaces remain protocol `1.0`; message send/stream fails before upstream HTTP |
| Shared session/model/stream/interrupt extensions | Declared and implemented as documented | Omitted from cards/OpenAPI; direct calls are unsupported |
| `opencode.sessions.*` (17 catalog methods) | Supported; `opencode.sessions.shell` is deployment-conditional | Omitted and rejected before upstream HTTP |
| Provider/model discovery (2 methods) | Supported | Omitted and rejected before upstream HTTP |
| Project/workspace/worktree (9 methods) | Project discovery stable; workspace/worktree discovery experimental; five mutations deployment-conditional | Omitted and rejected before upstream HTTP |
| Interrupt recovery (2 methods) | Adapter-local and caller-scoped | Omitted and rejected before upstream HTTP |
| `a2a.interrupt.*` callbacks (3 methods) | Supported through the shared interrupt extension | Omitted and rejected before upstream HTTP |
| Internal V2 session transport | Not applicable | `/api/info`, `/api/location`, session create/get/list only; exercised against `2.0.28`, not a model execution test |

V2 support does **not** claim A2A execution, task-scoped cancellation,
permission/form interventions, request-scoped model overrides, structured tool
execution, or outbound `a2a_call` support. `2.0.28` evidence is limited to
session transport; no V2 model-backed acceptance run is claimed.

V1 stays the default, and the adapter does not auto-detect or fall back between
versions. The V2 session transport was exercised against OpenCode `2.0.28`; that
experimental check does not add V2 to the supported runtime line above.
OpenCode V2 allows `external_directory` by default. Before any future release of
V2 prompt execution, operators must configure an explicit restrictive upstream
V2 `permissions` policy. The current session-only slice does not perform model
execution.

This is the single canonical place where the supported upstream OpenCode version line is declared. Keep it updated together with the live integration smoke check (`./scripts/live_opencode_smoke.sh`) and do not duplicate the version line elsewhere.

The SDK-owned core JSON-RPC method set follows the supported SDK line and is locked by repository tests so SDK upgrades trigger an explicit compatibility review.
Expand Down
7 changes: 7 additions & 0 deletions docs/extension-specifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ Provider-private contract note:
- Shared `metadata.shared.*` contracts are intended to remain low-risk and transportable.
- Compatibility and wire-contract URIs are descriptive metadata contracts, not activatable runtime capabilities.
- URI path segments identify the contract, not its auth or disclosure tier; public-vs-extended disclosure is controlled by Agent Card and OpenAPI surfaces.
- These declarations describe the V1 extension contract. With `OPENCODE_API_VERSION=v2`, the public and authenticated Agent Cards and OpenAPI publish no extension declarations or extension method metadata. Direct `opencode.*` and `a2a.interrupt.*` JSON-RPC calls return `-32601 Method not found` before any upstream HTTP request.

OpenCode API V2 is not A2A SDK V2 and does not enable this extension index.
V2's informational status skill and its non-empty Agent Card default modes are
schema/discovery requirements only; they do not claim A2A message execution,
session methods, model overrides, cancellation, or intervention support. See
[`compatibility.md`](./compatibility.md#runtime-support) for the current matrix.

## SDK and Discovery Compatibility

Expand Down
1 change: 1 addition & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Key variables to understand protocol behavior:

- `A2A_STATIC_AUTH_CREDENTIALS`: required static credential registry in JSON array form. Supports multiple `bearer` / `basic` credentials, bearer-only required `principal`, optional `credential_id`, optional `capabilities`, and optional `enabled=false` for explicit disablement.
- `OPENCODE_BASE_URL`: upstream OpenCode HTTP endpoint. Default: `http://127.0.0.1:4096`. In two-process deployments, set it explicitly.
- `OPENCODE_API_VERSION`: OpenCode upstream API selector, `v1` (default) or `v2`; this is unrelated to the `a2a-sdk` package version. Selection is explicit, with no auto-detection or fallback. V2 currently provides internal session transport only, not A2A execution. The A2A 1.0 interfaces remain on the card; the V2 status skill is informational, and SDK-required non-empty default input/output modes do not promise that messages are executable. `SendMessage` returns a failed task before session creation or prompt HTTP; `SendStreamingMessage` is rejected because V2 declares `streaming=false`. Direct extension calls return `-32601 Method not found` before upstream HTTP. OpenCode-specific and shared extension declarations remain hidden. See [compatibility](./compatibility.md#runtime-support) for pinned evidence and limitations.
- `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`.
Expand Down
11 changes: 7 additions & 4 deletions docs/maintainer-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ flowchart TD
end

subgraph Upstream["OpenCode Upstream Layer"]
UpstreamClient["opencode_upstream_client.py"]
UpstreamClient["opencode_upstream_client.py (V1 facade)"]
UpstreamContracts["upstream/contracts.py"]
end

subgraph Extensions["Extension / Contract Layer"]
Expand All @@ -37,7 +38,8 @@ flowchart TD

Inbound --> Execution
Inbound --> Extensions
Execution --> Upstream
Execution --> UpstreamContracts
UpstreamClient --> UpstreamContracts
Inbound --> Persistence
Execution --> Persistence
```
Expand All @@ -53,8 +55,9 @@ flowchart TD
- `execution/coordinator.py`
- `execution/stream_runtime.py`
- `execution/tool_orchestration.py`
5. `opencode_upstream_client.py` sends requests to the upstream OpenCode runtime.
6. The adapter maps upstream responses back into A2A tasks, messages, artifacts, and stream events.
5. `opencode_upstream_client.py` preserves the public V1 client facade and sends requests to the OpenCode runtime.
6. The internal `upstream/contracts.py` separates prompt receipts, terminal outcomes, and normalized execution updates; V1 event decoding occurs before updates reach the shared stream runtime.
7. The execution layer maps outcomes and updates into A2A tasks, messages, artifacts, and stream events.

### JSON-RPC Extension Path

Expand Down
2 changes: 2 additions & 0 deletions src/opencode_a2a/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
OutsideWorkspaceAccess = Literal["unknown", "allowed", "disallowed", "custom"]
TaskStoreBackend = Literal["memory", "database"]
StaticAuthScheme = Literal["bearer", "basic"]
OpencodeApiVersion = Literal["v1", "v2"]


def _parse_declared_list(value: Any) -> tuple[str, ...]:
Expand Down Expand Up @@ -148,6 +149,7 @@ class Settings(BaseSettings):
)

# OpenCode settings
opencode_api_version: OpencodeApiVersion = Field(default="v1", alias="OPENCODE_API_VERSION")
opencode_base_url: str = Field(default="http://127.0.0.1:4096", alias="OPENCODE_BASE_URL")
opencode_workspace_root: str | None = Field(default=None, alias="OPENCODE_WORKSPACE_ROOT")
opencode_agent: str | None = Field(default=None, alias="OPENCODE_AGENT")
Expand Down
7 changes: 6 additions & 1 deletion src/opencode_a2a/contracts/extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from __future__ import annotations

# ruff: noqa: F401
from .capabilities import JsonRpcCapabilitySnapshot, build_capability_snapshot
from .capabilities import (
DECLARED_EXTENSION_URIS,
JsonRpcCapabilitySnapshot,
OpenCodeApiVersion,
build_capability_snapshot,
)
from .catalog import (
COMMAND_REQUEST_ALLOWED_FIELDS,
INTERRUPT_CALLBACK_METHODS,
Expand Down
120 changes: 102 additions & 18 deletions src/opencode_a2a/contracts/extensions/capabilities.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

from collections.abc import Collection
from collections.abc import Collection, Mapping
from dataclasses import dataclass
from typing import Any
from types import MappingProxyType
from typing import Any, Literal

from ...profile.runtime import (
SESSION_SHELL_TOGGLE,
Expand All @@ -11,6 +12,29 @@
)
from . import catalog, identifiers

OpenCodeApiVersion = Literal["v1", "v2"]

DECLARED_EXTENSION_URIS: tuple[str, ...] = (
identifiers.SESSION_BINDING_EXTENSION_URI,
identifiers.MODEL_SELECTION_EXTENSION_URI,
identifiers.STREAMING_EXTENSION_URI,
identifiers.SESSION_MANAGEMENT_EXTENSION_URI,
identifiers.PROVIDER_DISCOVERY_EXTENSION_URI,
identifiers.WORKSPACE_CONTROL_EXTENSION_URI,
identifiers.INTERRUPT_RECOVERY_EXTENSION_URI,
identifiers.INTERRUPT_CALLBACK_EXTENSION_URI,
)

_ALL_EXTENSION_METHODS = frozenset(
(
*catalog.SESSION_METHODS.values(),
*catalog.PROVIDER_DISCOVERY_METHODS.values(),
*catalog.WORKSPACE_CONTROL_METHODS.values(),
*catalog.INTERRUPT_RECOVERY_METHODS.values(),
*catalog.INTERRUPT_CALLBACK_METHODS.values(),
)
)


@dataclass(frozen=True)
class DeploymentConditionalMethod:
Expand Down Expand Up @@ -46,47 +70,94 @@ def disabled_wire_contract_entry(self) -> dict[str, str] | None:

@dataclass(frozen=True)
class JsonRpcCapabilitySnapshot:
conditional_methods: dict[str, DeploymentConditionalMethod]
upstream_api_version: OpenCodeApiVersion
conditional_methods: Mapping[str, DeploymentConditionalMethod]

@property
def extensions_enabled(self) -> bool:
return self.upstream_api_version == "v1"

@property
def a2a_message_execution_enabled(self) -> bool:
return self.upstream_api_version == "v1"

@property
def streaming_enabled(self) -> bool:
return self.upstream_api_version == "v1"

@property
def declared_extension_uris(self) -> tuple[str, ...]:
return DECLARED_EXTENSION_URIS if self.extensions_enabled else ()

def is_method_enabled(self, method: str) -> bool:
if method in _ALL_EXTENSION_METHODS and not self.extensions_enabled:
return False
conditional_method = self.conditional_methods.get(method)
if conditional_method is None:
return True
return conditional_method.enabled

def session_management_methods(self) -> dict[str, str]:
if not self.extensions_enabled:
return {}
methods = dict(catalog.SESSION_METHODS)
if not self.is_method_enabled(catalog.SESSION_METHODS["shell"]):
methods.pop("shell", None)
return methods

def dispatch_method_bindings(self) -> dict[str, str]:
"""Return handler registry bindings; every call is still checked against this policy."""
methods = self.session_management_methods()
if not self.extensions_enabled:
methods = dict(catalog.SESSION_METHODS)
if not self._deployment_method_enabled(catalog.SESSION_METHODS["shell"]):
methods.pop("shell", None)
methods.update(catalog.PROVIDER_DISCOVERY_METHODS)
methods.update(self.dispatch_workspace_control_methods())
methods.update(catalog.INTERRUPT_RECOVERY_METHODS)
methods.update(catalog.INTERRUPT_CALLBACK_METHODS)
return methods

def _deployment_method_enabled(self, method: str) -> bool:
conditional_method = self.conditional_methods.get(method)
return conditional_method is None or conditional_method.enabled

def dispatch_workspace_control_methods(self) -> dict[str, str]:
methods = dict(catalog.WORKSPACE_DISCOVERY_METHODS)
for key, method in catalog.WORKSPACE_MUTATION_METHODS.items():
if self._deployment_method_enabled(method):
methods[key] = method
return methods

def session_control_methods(self) -> dict[str, str]:
if not self.extensions_enabled:
return {}
methods = dict(catalog.SESSION_CONTROL_METHODS)
if not self.is_method_enabled(catalog.SESSION_CONTROL_METHODS["shell"]):
methods.pop("shell", None)
return methods

def workspace_control_methods(self) -> dict[str, str]:
methods = dict(catalog.WORKSPACE_DISCOVERY_METHODS)
for key, method in catalog.WORKSPACE_MUTATION_METHODS.items():
if self.is_method_enabled(method):
methods[key] = method
return methods
if not self.extensions_enabled:
return {}
return self.dispatch_workspace_control_methods()

def supported_jsonrpc_methods(self) -> list[str]:
methods = [
*catalog.CORE_JSONRPC_METHODS,
*(method for key, method in catalog.SESSION_METHODS.items() if key != "shell"),
*catalog.PROVIDER_DISCOVERY_METHODS.values(),
*self.workspace_control_methods().values(),
*catalog.INTERRUPT_RECOVERY_METHODS.values(),
*catalog.INTERRUPT_CALLBACK_METHODS.values(),
]
methods = list(catalog.CORE_JSONRPC_METHODS)
if not self.extensions_enabled:
return methods
methods.extend(method for key, method in catalog.SESSION_METHODS.items() if key != "shell")
methods.extend(catalog.PROVIDER_DISCOVERY_METHODS.values())
methods.extend(self.workspace_control_methods().values())
methods.extend(catalog.INTERRUPT_RECOVERY_METHODS.values())
methods.extend(catalog.INTERRUPT_CALLBACK_METHODS.values())
if self.is_method_enabled(catalog.SESSION_CONTROL_METHODS["shell"]):
methods.append(catalog.SESSION_CONTROL_METHODS["shell"])
return methods

def extension_jsonrpc_methods(self) -> list[str]:
if not self.extensions_enabled:
return []
methods = [
*(method for key, method in catalog.SESSION_METHODS.items() if key != "shell"),
*catalog.PROVIDER_DISCOVERY_METHODS.values(),
Expand All @@ -99,27 +170,37 @@ def extension_jsonrpc_methods(self) -> list[str]:
return methods

def conditionally_available_methods(self) -> dict[str, dict[str, str]]:
if not self.extensions_enabled:
return {}
return {
method: disabled_entry
for method, conditional_method in self.conditional_methods.items()
if (disabled_entry := conditional_method.disabled_wire_contract_entry()) is not None
}

def method_flags(self, methods: Collection[str]) -> dict[str, dict[str, Any]]:
if not self.extensions_enabled:
return {}
return {
method: conditional_method.control_method_flag()
for method, conditional_method in self.conditional_methods.items()
if method in methods
}

def conditional_method_retention(self) -> dict[str, dict[str, Any]]:
if not self.extensions_enabled:
return {}
return {
method: conditional_method.method_retention()
for method, conditional_method in self.conditional_methods.items()
}


def build_capability_snapshot(*, runtime_profile: RuntimeProfile) -> JsonRpcCapabilitySnapshot:
def build_capability_snapshot(
*,
runtime_profile: RuntimeProfile,
upstream_api_version: OpenCodeApiVersion = "v1",
) -> JsonRpcCapabilitySnapshot:
conditional_methods = {
catalog.SESSION_CONTROL_METHODS["shell"]: DeploymentConditionalMethod(
method=catalog.SESSION_CONTROL_METHODS["shell"],
Expand All @@ -139,4 +220,7 @@ def build_capability_snapshot(*, runtime_profile: RuntimeProfile) -> JsonRpcCapa
for method in catalog.WORKSPACE_MUTATION_METHODS.values()
}
)
return JsonRpcCapabilitySnapshot(conditional_methods=conditional_methods)
return JsonRpcCapabilitySnapshot(
upstream_api_version=upstream_api_version,
conditional_methods=MappingProxyType(conditional_methods),
)
Loading