Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This document defines the compatibility promises `opencode-a2a` currently uphold
- A2A SDK line: `1.x.y`
- Supported A2A protocol line: `1.0`

The repository currently pins one concrete SDK release in `pyproject.toml` within that v1 line. Upgrade the SDK deliberately rather than relying on floating dependency resolution.
The repository currently pins one concrete SDK release in `pyproject.toml` within that v1 line. Upgrade the SDK deliberately rather than relying on floating dependency resolution. The SDK-owned core JSON-RPC method set follows that pinned release and is locked by repository tests so SDK upgrades trigger an explicit compatibility review.

## Contract Honesty

Expand Down Expand Up @@ -53,6 +53,7 @@ This repository still ships as an alpha project. Within that alpha line, these d
- authenticated extended card and OpenAPI wire-contract metadata

Changes to those surfaces should be treated as compatibility-sensitive and should include corresponding test updates.
- provider-private session query params use one top-level request shape; the repository does not promise parallel nested `query.*` aliases for the same filters.

Service-level behavior layered on top of those core methods should also be declared explicitly when interoperability depends on it. Current examples:

Expand Down
3 changes: 3 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ Current behavior:
- `all_jsonrpc_methods` is the runtime truth for the current deployment.
- The current SDK-owned core JSON-RPC surface includes `GetExtendedAgentCard` and `tasks/pushNotificationConfig/*`.
- The current SDK-owned REST surface also includes `GET /v1/tasks` and the task push notification config routes.
- The SDK-owned core JSON-RPC method set follows the pinned `a2a-sdk` release and is locked by repository tests; review that surface deliberately when upgrading the SDK.
- 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`.
Expand Down Expand Up @@ -621,11 +622,13 @@ Detailed contract discovery for this provider-private surface is intentionally a
- Session list filters:
- optional `directory`, `roots`, `start`, `search`, `limit`
- optional `metadata.opencode.workspace.id`
- nested `query` objects are not supported; pass filters at the top level only
- `directory` is normalized through the same workspace-boundary rules used by other OpenCode directory overrides before reaching upstream
- when `metadata.opencode.workspace.id` is present, the adapter routes by workspace and ignores `directory`
- Session message history filters:
- optional `limit`, `before`
- optional `metadata.opencode.workspace.id`
- nested `query` objects are not supported; pass filters at the top level only
- `before` is an opaque cursor for loading older messages and is only supported on `opencode.sessions.messages.list`
- Mutation methods:
- `opencode.sessions.fork`
Expand Down
4 changes: 2 additions & 2 deletions src/opencode_a2a/client/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@ def _normalize_transport(value: str) -> str:
normalized = value.strip().lower()
if normalized in {"jsonrpc", "json-rpc", "json_rpc"}:
return "JSONRPC"
if normalized in {"http+json", "http_json", "http-json", "httpjson", "http+json+"}:
if normalized in {"http+json", "http-json", "http_json"}:
return "HTTP+JSON"
if normalized in {"grpc"}:
return "GRPC"
if not normalized:
return "JSONRPC"
return value.strip()
raise ValueError(f"supported_transports contains unsupported transport {value!r}")


def _parse_transports(
Expand Down
7 changes: 0 additions & 7 deletions src/opencode_a2a/contracts/extensions/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,6 @@ class WorkspaceControlMethodContract:
"roots",
"start",
"search",
"query.limit",
"query.directory",
"query.roots",
"query.start",
"query.search",
),
unsupported_params=SESSION_QUERY_PAGINATION_UNSUPPORTED,
result_fields=("items",),
Expand All @@ -158,8 +153,6 @@ class WorkspaceControlMethodContract:
"limit",
"before",
OPENCODE_WORKSPACE_METADATA_FIELD,
"query.limit",
"query.before",
),
unsupported_params=SESSION_QUERY_PAGINATION_UNSUPPORTED,
result_fields=("items", "next_cursor"),
Expand Down
67 changes: 19 additions & 48 deletions src/opencode_a2a/jsonrpc/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,18 @@ def _validation_error(field: str, message: str) -> JsonRpcParamsValidationError:
)


def _parse_query_object(params: dict[str, Any]) -> dict[str, Any]:
raw_query = params.get("query")
if raw_query is None:
return {}
if not isinstance(raw_query, dict):
raise JsonRpcParamsValidationError(
message="query must be an object",
data={"type": "INVALID_FIELD", "field": "query"},
)
return dict(raw_query)
def _reject_nested_query_params(params: dict[str, Any]) -> None:
if "query" not in params:
return
raise JsonRpcParamsValidationError(
message="query is not supported; use top-level params",
data={"type": "INVALID_FIELD", "field": "query"},
)


def _validate_pagination_fields(params: dict[str, Any], query: dict[str, Any]) -> None:
def _validate_pagination_fields(params: dict[str, Any]) -> None:
unsupported_fields = tuple(SESSION_QUERY_PAGINATION_UNSUPPORTED)
if any(field in params for field in unsupported_fields) or any(
field in query for field in unsupported_fields
):
if any(field in params for field in unsupported_fields):
raise JsonRpcParamsValidationError(
message="Only limit pagination is supported",
data={
Expand All @@ -81,18 +76,9 @@ def _validate_pagination_fields(params: dict[str, Any], query: dict[str, Any]) -

def _normalize_session_query_limit(
*,
params: dict[str, Any],
query: dict[str, Any],
limit: Any,
) -> dict[str, Any]:
top_level_limit = _parse_required_positive_int(params.get("limit"), field="limit")
query_limit = _parse_required_positive_int(query.get("limit"), field="limit")
if top_level_limit is not None and query_limit is not None and top_level_limit != query_limit:
raise JsonRpcParamsValidationError(
message="limit is ambiguous between params.limit and params.query.limit",
data={"type": "INVALID_FIELD", "field": "limit"},
)

normalized_limit = top_level_limit if top_level_limit is not None else query_limit
normalized_limit = _parse_required_positive_int(limit, field="limit")
if normalized_limit is None:
normalized_limit = SESSION_QUERY_DEFAULT_LIMIT
elif normalized_limit > SESSION_QUERY_MAX_LIMIT:
Expand All @@ -105,53 +91,39 @@ def _normalize_session_query_limit(
},
)

normalized_query = dict(query)
normalized_query["limit"] = normalized_limit
return normalized_query
return {"limit": normalized_limit}


def _normalize_alias_field(
*,
params: dict[str, Any],
query: dict[str, Any],
field: str,
parser,
) -> Any:
top_level_value = parser(params.get(field), field=field)
query_value = parser(query.get(field), field=field)
if top_level_value is not None and query_value is not None and top_level_value != query_value:
raise JsonRpcParamsValidationError(
message=f"{field} is ambiguous between params.{field} and params.query.{field}",
data={"type": "INVALID_FIELD", "field": field},
)
return top_level_value if top_level_value is not None else query_value
return parser(params.get(field), field=field)


def parse_list_sessions_params(params: dict[str, Any]) -> dict[str, Any]:
query = _parse_query_object(params)
_validate_pagination_fields(params, query)
normalized_query = _normalize_session_query_limit(params=params, query=query)
_reject_nested_query_params(params)
_validate_pagination_fields(params)
normalized_query = _normalize_session_query_limit(limit=params.get("limit"))
directory = _normalize_alias_field(
params=params,
query=query,
field="directory",
parser=_parse_string_field,
)
roots = _normalize_alias_field(
params=params,
query=query,
field="roots",
parser=_parse_bool_field,
)
start = _normalize_alias_field(
params=params,
query=query,
field="start",
parser=_parse_non_negative_int,
)
search = _normalize_alias_field(
params=params,
query=query,
field="search",
parser=_parse_string_field,
)
Expand Down Expand Up @@ -183,12 +155,11 @@ def parse_get_session_messages_params(params: dict[str, Any]) -> tuple[str, dict
data={"type": "MISSING_FIELD", "field": "session_id"},
)

query = _parse_query_object(params)
_validate_pagination_fields(params, query)
normalized_query = _normalize_session_query_limit(params=params, query=query)
_reject_nested_query_params(params)
_validate_pagination_fields(params)
normalized_query = _normalize_session_query_limit(limit=params.get("limit"))
before = _normalize_alias_field(
params=params,
query=query,
field="before",
parser=_parse_string_field,
)
Expand Down
12 changes: 12 additions & 0 deletions tests/client/test_client_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ def test_load_settings_invalid_transport_raises() -> None:
load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": 1})


@pytest.mark.parametrize("value", ("httpjson", "http+json+"))
def test_load_settings_rejects_obsolete_transport_aliases(value: str) -> None:
with pytest.raises(ValueError, match="unsupported transport"):
load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": value})


def test_load_settings_accepts_underscore_transport_aliases() -> None:
settings = load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": "json_rpc,http_json"})

assert settings.supported_transports == ("JSONRPC", "HTTP+JSON")


def test_load_settings_invalid_bool_raises() -> None:
with pytest.raises(ValueError, match="boolean-like"):
load_settings({"A2A_CLIENT_USE_CLIENT_PREFERENCE": "maybe"})
Expand Down
30 changes: 30 additions & 0 deletions tests/config/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
A2A_PROTOCOL_VERSION,
A2A_SUPPORTED_PROTOCOL_VERSIONS,
)
from tests.support.settings import make_settings


def test_settings_missing_required():
Expand Down Expand Up @@ -93,6 +94,35 @@ def test_settings_valid():
assert A2A_SUPPORTED_PROTOCOL_VERSIONS == ("1.0",)


def test_make_settings_ignores_ambient_environment_sources(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text(
"\n".join(
[
"OPENCODE_BASE_URL=http://dotenv-upstream.test",
"A2A_PUBLIC_URL=http://dotenv-public.test",
"A2A_HOST=dotenv-host",
]
),
encoding="utf-8",
)

with mock.patch.dict(
os.environ,
{
"OPENCODE_BASE_URL": "http://env-upstream.test",
"A2A_PUBLIC_URL": "http://env-public.test",
"A2A_HOST": "env-host",
},
clear=False,
):
settings = make_settings()

assert settings.opencode_base_url == "http://127.0.0.1:4096"
assert settings.a2a_public_url == "http://127.0.0.1:8000"
assert settings.a2a_host == "127.0.0.1"


def test_settings_allow_explicit_memory_backend() -> None:
env = {
"A2A_STATIC_AUTH_CREDENTIALS": json.dumps(
Expand Down
2 changes: 1 addition & 1 deletion tests/contracts/test_extension_contract_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@
from tests.support.helpers import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.helpers import make_settings
from tests.support.session_extensions import _extension_headers
from tests.support.settings import make_settings


@pytest.mark.parametrize(
Expand Down
3 changes: 2 additions & 1 deletion tests/execution/test_directory_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

from opencode_a2a.execution.executor import OpencodeAgentExecutor
from opencode_a2a.opencode_upstream_client import OpencodeUpstreamClient
from tests.support.helpers import make_request_context_mock, make_settings
from tests.support.helpers import make_request_context_mock
from tests.support.settings import make_settings


@pytest.fixture
Expand Down
3 changes: 2 additions & 1 deletion tests/execution/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@

from opencode_a2a.execution.executor import OpencodeAgentExecutor, _StreamOutputState
from opencode_a2a.server.application import OpencodeRequestHandler
from tests.support.helpers import DummyEventQueue, make_settings
from tests.support.helpers import DummyEventQueue
from tests.support.settings import make_settings


def _make_message_send_params() -> SendMessageRequest:
Expand Down
3 changes: 2 additions & 1 deletion tests/execution/test_multipart_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from opencode_a2a.a2a_utils import make_data_part
from opencode_a2a.execution.executor import OpencodeAgentExecutor
from opencode_a2a.opencode_upstream_client import OpencodeMessage
from tests.support.helpers import DummyEventQueue, make_request_context_with_parts, make_settings
from tests.support.helpers import DummyEventQueue, make_request_context_with_parts
from tests.support.settings import make_settings


class RecordingMultipartClient:
Expand Down
2 changes: 1 addition & 1 deletion tests/execution/test_session_ownership.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
from tests.support.helpers import (
configure_mock_client_runtime,
make_request_context_mock,
make_settings,
)
from tests.support.settings import make_settings


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion tests/execution/test_streaming_output_contract_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
from tests.support.helpers import (
DummyEventQueue,
make_request_context,
make_settings,
)
from tests.support.settings import make_settings
from tests.support.streaming_output import (
DummyStreamingClient,
_artifact_stream_meta,
Expand Down
3 changes: 2 additions & 1 deletion tests/jsonrpc/test_application_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
from opencode_a2a.contracts.extensions import SESSION_MANAGEMENT_EXTENSION_URI
from opencode_a2a.jsonrpc.models import JSONRPCRequest
from tests.support.async_iterators import iter_async
from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient, make_settings
from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient
from tests.support.session_extensions import _BASE_SETTINGS, _jsonrpc_app
from tests.support.settings import make_settings


def _build_dispatcher(monkeypatch: pytest.MonkeyPatch):
Expand Down
20 changes: 15 additions & 5 deletions tests/jsonrpc/test_dispatch_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
from opencode_a2a.a2a_protocol import CORE_JSONRPC_METHODS
from opencode_a2a.contracts.extensions import SESSION_MANAGEMENT_EXTENSION_URI
from opencode_a2a.jsonrpc.application import OpencodeSessionManagementJSONRPCApplication
from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient, make_settings
from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient
from tests.support.jsonrpc_error_assertions import assert_v1_error_reason, error_context_detail
from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers, _jsonrpc_app
from tests.support.settings import make_settings


@pytest.mark.asyncio
Expand Down Expand Up @@ -100,10 +101,19 @@ async def _fake_core_handle(self, request, body, base_request): # noqa: ANN001


def test_core_jsonrpc_methods_are_canonical_pascalcase() -> None:
assert "SendMessage" in CORE_JSONRPC_METHODS
assert "SendStreamingMessage" in CORE_JSONRPC_METHODS
assert "GetTask" in CORE_JSONRPC_METHODS
assert "CancelTask" in CORE_JSONRPC_METHODS
assert CORE_JSONRPC_METHODS == (
"SendMessage",
"SendStreamingMessage",
"GetTask",
"ListTasks",
"CancelTask",
"CreateTaskPushNotificationConfig",
"GetTaskPushNotificationConfig",
"ListTaskPushNotificationConfigs",
"DeleteTaskPushNotificationConfig",
"SubscribeToTask",
"GetExtendedAgentCard",
)
assert "message/send" not in CORE_JSONRPC_METHODS
assert "tasks/get" not in CORE_JSONRPC_METHODS

Expand Down
Loading
Loading