From 4c651bd0a03ffb40809e89d868b6435886038606 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 02:18:37 -0500 Subject: [PATCH 1/8] =?UTF-8?q?[chore]=20=E6=B8=85=E7=90=86=E5=86=97?= =?UTF-8?q?=E4=BD=99=E4=B8=8E=E9=87=8D=E5=A4=8D=E5=AE=9E=E7=8E=B0=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/opencode_a2a_serve/agent.py | 86 ++++++++------------ src/opencode_a2a_serve/app.py | 31 +++---- tests/__init__.py | 1 + tests/helpers.py | 25 ++++++ tests/test_agent_card.py | 32 +++----- tests/test_opencode_agent_session_binding.py | 20 +---- tests/test_opencode_client_params.py | 31 +------ tests/test_opencode_session_extension.py | 31 +------ tests/test_streaming_output_contract.py | 19 +---- tests/test_transport_contract.py | 16 ++-- 10 files changed, 108 insertions(+), 184 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/helpers.py diff --git a/src/opencode_a2a_serve/agent.py b/src/opencode_a2a_serve/agent.py index f1b2522..699e744 100644 --- a/src/opencode_a2a_serve/agent.py +++ b/src/opencode_a2a_serve/agent.py @@ -1142,72 +1142,58 @@ def _extract_stream_role(part: Mapping[str, Any], props: Mapping[str, Any]) -> s return _normalize_role(role) +def _extract_first_nonempty_string( + source: Mapping[str, Any] | None, + keys: tuple[str, ...], +) -> str | None: + if not isinstance(source, Mapping): + return None + for key in keys: + value = source.get(key) + if isinstance(value, str): + normalized = value.strip() + if normalized: + return normalized + return None + + def _extract_stream_session_id(part: Mapping[str, Any], props: Mapping[str, Any]) -> str | None: session_keys = ("sessionID", "sessionId", "session_id") - for key in session_keys: - value = part.get(key) - if isinstance(value, str) and value: - return value - for key in session_keys: - value = props.get(key) - if isinstance(value, str) and value: - return value + for source in (part, props): + candidate = _extract_first_nonempty_string(source, session_keys) + if candidate: + return candidate message = props.get("message") - if isinstance(message, Mapping): - for key in session_keys: - value = message.get(key) - if isinstance(value, str) and value: - return value + candidate = _extract_first_nonempty_string(message, session_keys) + if candidate: + return candidate return None def _extract_stream_message_id(part: Mapping[str, Any], props: Mapping[str, Any]) -> str | None: message_keys = ("messageID", "messageId", "message_id", "id") - for key in message_keys: - value = part.get(key) - if isinstance(value, str): - normalized = value.strip() - if normalized: - return normalized - for key in message_keys: - value = props.get(key) - if isinstance(value, str): - normalized = value.strip() - if normalized: - return normalized + for source in (part, props): + candidate = _extract_first_nonempty_string(source, message_keys) + if candidate: + return candidate message = props.get("message") if isinstance(message, Mapping): - for key in message_keys: - value = message.get(key) - if isinstance(value, str): - normalized = value.strip() - if normalized: - return normalized + candidate = _extract_first_nonempty_string(message, message_keys) + if candidate: + return candidate info = message.get("info") - if isinstance(info, Mapping): - for key in message_keys: - value = info.get(key) - if isinstance(value, str): - normalized = value.strip() - if normalized: - return normalized + candidate = _extract_first_nonempty_string(info, message_keys) + if candidate: + return candidate return None def _extract_stream_part_id(part: Mapping[str, Any], props: Mapping[str, Any]) -> str | None: part_keys = ("partID", "partId", "part_id", "id") - for key in part_keys: - value = part.get(key) - if isinstance(value, str): - normalized = value.strip() - if normalized: - return normalized - for key in part_keys: - value = props.get(key) - if isinstance(value, str): - normalized = value.strip() - if normalized: - return normalized + for source in (part, props): + candidate = _extract_first_nonempty_string(source, part_keys) + if candidate: + return candidate return None diff --git a/src/opencode_a2a_serve/app.py b/src/opencode_a2a_serve/app.py index 1d850b7..aa1d0ff 100644 --- a/src/opencode_a2a_serve/app.py +++ b/src/opencode_a2a_serve/app.py @@ -149,7 +149,6 @@ def build_agent_card(settings: Settings) -> AgentCard: security: list[dict[str, list[str]]] = [{"bearerAuth": []}] if settings.a2a_oauth_authorization_url and settings.a2a_oauth_token_url: - security_schemes = security_schemes or {} security_schemes["oauth2"] = SecurityScheme( root=OAuth2SecurityScheme( oauth2_metadata_url=settings.a2a_oauth_metadata_url, @@ -163,7 +162,6 @@ def build_agent_card(settings: Settings) -> AgentCard: ), ) ) - security = security or [] security.append({"oauth2": list(settings.a2a_oauth_scopes.keys())}) return AgentCard( @@ -352,12 +350,15 @@ async def lifespan(_app: FastAPI): for route, callback in rest_adapter.routes().items(): app.add_api_route(route[0], callback, methods=[route[1]]) - def _detect_opencode_session_query_method(body_bytes: bytes) -> str | None: + def _parse_json_body(body_bytes: bytes) -> dict | None: try: payload = json.loads(body_bytes.decode("utf-8", errors="replace")) except Exception: return None - if not isinstance(payload, dict): + return payload if isinstance(payload, dict) else None + + def _detect_opencode_session_query_method(payload: dict | None) -> str | None: + if payload is None: return None method = payload.get("method") if not isinstance(method, str): @@ -366,12 +367,8 @@ def _detect_opencode_session_query_method(body_bytes: bytes) -> str | None: return method return None - def _looks_like_jsonrpc_message_payload(raw: bytes) -> bool: - try: - payload = json.loads(raw.decode("utf-8", errors="replace")) - except Exception: - return False - if not isinstance(payload, dict): + def _looks_like_jsonrpc_message_payload(payload: dict | None) -> bool: + if payload is None: return False message = payload.get("message") if not isinstance(message, dict): @@ -381,12 +378,8 @@ def _looks_like_jsonrpc_message_payload(raw: bytes) -> bool: role = message.get("role") return isinstance(role, str) and role in {"user", "agent"} - def _looks_like_jsonrpc_envelope(raw: bytes) -> bool: - try: - payload = json.loads(raw.decode("utf-8", errors="replace")) - except Exception: - return False - if not isinstance(payload, dict): + def _looks_like_jsonrpc_envelope(payload: dict | None) -> bool: + if payload is None: return False method = payload.get("method") version = payload.get("jsonrpc") @@ -402,7 +395,8 @@ async def guard_rest_payload_shape(request: Request, call_next): body = await request.body() request._body = body # allow downstream to read again - if _looks_like_jsonrpc_envelope(body) or _looks_like_jsonrpc_message_payload(body): + payload = _parse_json_body(body) + if _looks_like_jsonrpc_envelope(payload) or _looks_like_jsonrpc_message_payload(payload): return JSONResponse( { "error": ( @@ -424,7 +418,8 @@ async def log_payloads(request: Request, call_next): request._body = body # allow downstream to read again path = request.url.path # Detect session-query JSON-RPC methods regardless of deployment prefixes/root_path. - sensitive_method = _detect_opencode_session_query_method(body) + payload = _parse_json_body(body) + sensitive_method = _detect_opencode_session_query_method(payload) if sensitive_method: logger.debug("A2A request %s %s method=%s", request.method, path, sensitive_method) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..38bb211 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package.""" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..860fdff --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +from opencode_a2a_serve.config import Settings + + +def make_settings(**overrides: Any) -> Settings: + base: dict[str, Any] = { + "opencode_base_url": "http://127.0.0.1:4096", + "a2a_bearer_token": "test-token", + } + base.update(overrides) + return Settings(**base) + + +class DummyEventQueue: + def __init__(self) -> None: + self.events: list[Any] = [] + + async def enqueue_event(self, event: Any) -> None: + self.events.append(event) + + async def close(self) -> None: + return None diff --git a/tests/test_agent_card.py b/tests/test_agent_card.py index 8bf20c0..f457803 100644 --- a/tests/test_agent_card.py +++ b/tests/test_agent_card.py @@ -3,20 +3,11 @@ SESSION_QUERY_EXTENSION_URI, build_agent_card, ) -from opencode_a2a_serve.config import Settings - - -def _settings(**overrides) -> Settings: - base = { - "A2A_BEARER_TOKEN": "test-token", - "OPENCODE_BASE_URL": "http://127.0.0.1:4096", - } - base.update(overrides) - return Settings(**base) +from tests.helpers import make_settings def test_agent_card_description_reflects_actual_transport_capabilities() -> None: - card = build_agent_card(_settings()) + card = build_agent_card(make_settings(a2a_bearer_token="test-token")) assert "HTTP+JSON and JSON-RPC transports" in card.description assert "message/send, message/stream" in card.description @@ -28,14 +19,15 @@ def test_agent_card_description_reflects_actual_transport_capabilities() -> None def test_agent_card_injects_deployment_context_into_extensions() -> None: card = build_agent_card( - _settings( - A2A_PROJECT="alpha", - OPENCODE_DIRECTORY="/srv/workspaces/alpha", - OPENCODE_PROVIDER_ID="google", - OPENCODE_MODEL_ID="gemini-2.5-flash", - OPENCODE_AGENT="code-reviewer", - OPENCODE_VARIANT="safe", - A2A_ALLOW_DIRECTORY_OVERRIDE="false", + make_settings( + a2a_bearer_token="test-token", + a2a_project="alpha", + opencode_directory="/srv/workspaces/alpha", + opencode_provider_id="google", + opencode_model_id="gemini-2.5-flash", + opencode_agent="code-reviewer", + opencode_variant="safe", + a2a_allow_directory_override=False, ) ) ext_by_uri = {ext.uri: ext for ext in card.capabilities.extensions or []} @@ -61,6 +53,6 @@ def test_agent_card_injects_deployment_context_into_extensions() -> None: def test_agent_card_chat_examples_include_project_hint_when_configured() -> None: - card = build_agent_card(_settings(A2A_PROJECT="alpha")) + card = build_agent_card(make_settings(a2a_bearer_token="test-token", a2a_project="alpha")) chat_skill = next(skill for skill in card.skills if skill.id == "opencode.chat") assert any("project alpha" in example for example in chat_skill.examples) diff --git a/tests/test_opencode_agent_session_binding.py b/tests/test_opencode_agent_session_binding.py index 32c3ae9..989d397 100644 --- a/tests/test_opencode_agent_session_binding.py +++ b/tests/test_opencode_agent_session_binding.py @@ -6,17 +6,7 @@ from opencode_a2a_serve.agent import OpencodeAgentExecutor from opencode_a2a_serve.opencode_client import OpencodeMessage - - -class DummyEventQueue: - def __init__(self) -> None: - self.events = [] - - async def enqueue_event(self, event) -> None: # noqa: ANN001 - self.events.append(event) - - async def close(self) -> None: - return None +from tests.helpers import DummyEventQueue, make_settings class DummyOpencodeClient: @@ -25,11 +15,9 @@ def __init__(self) -> None: self.sent_session_ids: list[str] = [] self.stream_timeout = None self.directory = None - from opencode_a2a_serve.config import Settings - - self.settings = Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", + self.settings = make_settings( + a2a_bearer_token="test", + opencode_base_url="http://localhost", ) async def create_session( diff --git a/tests/test_opencode_client_params.py b/tests/test_opencode_client_params.py index e9d65ac..2bf74c5 100644 --- a/tests/test_opencode_client_params.py +++ b/tests/test_opencode_client_params.py @@ -1,39 +1,16 @@ import pytest -from opencode_a2a_serve.config import Settings from opencode_a2a_serve.opencode_client import OpencodeClient +from tests.helpers import make_settings -def _settings(*, directory: str | None) -> Settings: - return Settings( - opencode_base_url="http://127.0.0.1:4096", +def _settings(*, directory: str | None): + return make_settings( + a2a_bearer_token="t-1", opencode_directory=directory, - opencode_provider_id=None, - opencode_model_id=None, - opencode_agent=None, - opencode_system=None, - opencode_variant=None, opencode_timeout=1.0, - opencode_timeout_stream=None, - a2a_public_url="http://127.0.0.1:8000", - a2a_title="OpenCode A2A", - a2a_description="A2A wrapper service for OpenCode", - a2a_version="0.1.0", - a2a_protocol_version="0.3.0", - a2a_streaming=True, a2a_log_level="DEBUG", a2a_log_payloads=False, - a2a_log_body_limit=0, - a2a_documentation_url=None, - a2a_host="127.0.0.1", - a2a_port=8000, - a2a_bearer_token="t-1", - a2a_oauth_authorization_url=None, - a2a_oauth_token_url=None, - a2a_oauth_metadata_url=None, - a2a_oauth_scopes={}, - a2a_session_cache_ttl_seconds=3600, - a2a_session_cache_maxsize=10_000, ) diff --git a/tests/test_opencode_session_extension.py b/tests/test_opencode_session_extension.py index de37a4e..9df2c9d 100644 --- a/tests/test_opencode_session_extension.py +++ b/tests/test_opencode_session_extension.py @@ -4,6 +4,7 @@ import pytest from opencode_a2a_serve.config import Settings +from tests.helpers import make_settings class DummyOpencodeClient: @@ -27,35 +28,11 @@ async def list_messages(self, session_id: str, *, params=None): def _settings(*, token: str, log_payloads: bool) -> Settings: - return Settings( - opencode_base_url="http://127.0.0.1:4096", - opencode_directory=None, - opencode_provider_id=None, - opencode_model_id=None, - opencode_agent=None, - opencode_system=None, - opencode_variant=None, + return make_settings( + a2a_bearer_token=token, + a2a_log_payloads=log_payloads, opencode_timeout=1.0, - opencode_timeout_stream=None, - a2a_public_url="http://127.0.0.1:8000", - a2a_title="OpenCode A2A", - a2a_description="A2A wrapper service for OpenCode", - a2a_version="0.1.0", - a2a_protocol_version="0.3.0", - a2a_streaming=True, a2a_log_level="DEBUG", - a2a_log_payloads=log_payloads, - a2a_log_body_limit=0, - a2a_documentation_url=None, - a2a_host="127.0.0.1", - a2a_port=8000, - a2a_bearer_token=token, - a2a_oauth_authorization_url=None, - a2a_oauth_token_url=None, - a2a_oauth_metadata_url=None, - a2a_oauth_scopes={}, - a2a_session_cache_ttl_seconds=3600, - a2a_session_cache_maxsize=10_000, ) diff --git a/tests/test_streaming_output_contract.py b/tests/test_streaming_output_contract.py index 3ebdf56..9e88b03 100644 --- a/tests/test_streaming_output_contract.py +++ b/tests/test_streaming_output_contract.py @@ -5,19 +5,8 @@ from a2a.types import Message, MessageSendParams, Role, TaskArtifactUpdateEvent, TextPart from opencode_a2a_serve.agent import OpencodeAgentExecutor -from opencode_a2a_serve.config import Settings from opencode_a2a_serve.opencode_client import OpencodeMessage - - -class DummyEventQueue: - def __init__(self) -> None: - self.events = [] - - async def enqueue_event(self, event) -> None: # noqa: ANN001 - self.events.append(event) - - async def close(self) -> None: - return None +from tests.helpers import DummyEventQueue, make_settings class DummyStreamingClient: @@ -37,9 +26,9 @@ def __init__( self.max_in_flight_send = 0 self.stream_timeout = None self.directory = None - self.settings = Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", + self.settings = make_settings( + a2a_bearer_token="test", + opencode_base_url="http://localhost", ) async def create_session( diff --git a/tests/test_transport_contract.py b/tests/test_transport_contract.py index aea4b62..77f3e46 100644 --- a/tests/test_transport_contract.py +++ b/tests/test_transport_contract.py @@ -5,17 +5,11 @@ from opencode_a2a_serve.app import build_agent_card, create_app from opencode_a2a_serve.config import Settings from opencode_a2a_serve.opencode_client import OpencodeMessage - - -def _settings() -> Settings: - return Settings( - opencode_base_url="http://127.0.0.1:4096", - a2a_bearer_token="test-token", - ) +from tests.helpers import make_settings def test_agent_card_declares_dual_stack_with_http_json_preferred() -> None: - card = build_agent_card(_settings()) + card = build_agent_card(make_settings(a2a_bearer_token="test-token")) assert card.preferred_transport == TransportProtocol.http_json transports = {iface.transport for iface in card.additional_interfaces or []} @@ -24,7 +18,7 @@ def test_agent_card_declares_dual_stack_with_http_json_preferred() -> None: def test_rest_subscription_route_matches_current_sdk_contract() -> None: - app = create_app(_settings()) + app = create_app(make_settings(a2a_bearer_token="test-token")) route_paths = {route.path for route in app.router.routes if hasattr(route, "path")} assert "/v1/tasks/{id}:subscribe" in route_paths @@ -76,7 +70,7 @@ async def test_dual_stack_send_accepts_transport_native_payloads(monkeypatch) -> import opencode_a2a_serve.app as app_module monkeypatch.setattr(app_module, "OpencodeClient", DummyOpencodeClient) - app = app_module.create_app(_settings()) + app = app_module.create_app(make_settings(a2a_bearer_token="test-token")) transport = httpx.ASGITransport(app=app) headers = {"Authorization": "Bearer test-token"} @@ -114,7 +108,7 @@ async def test_dual_stack_send_rejects_cross_transport_payload_shapes(monkeypatc import opencode_a2a_serve.app as app_module monkeypatch.setattr(app_module, "OpencodeClient", DummyOpencodeClient) - app = app_module.create_app(_settings()) + app = app_module.create_app(make_settings(a2a_bearer_token="test-token")) transport = httpx.ASGITransport(app=app) headers = {"Authorization": "Bearer test-token"} From 28c5daadb9a02b1b87c8bebc94edfcda3881a771 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 02:27:27 -0500 Subject: [PATCH 2/8] =?UTF-8?q?[chore]=20=E7=BB=A7=E7=BB=AD=E6=B8=85?= =?UTF-8?q?=E7=90=86=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81=E5=B9=B6=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=E5=85=B1=E4=BA=AB=20helper=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/opencode_a2a_serve/jsonrpc_ext.py | 14 +- src/opencode_a2a_serve/opencode_client.py | 12 +- src/opencode_a2a_serve/text_parts.py | 15 +++ tests/helpers.py | 48 +++++++ tests/test_agent_errors.py | 33 ++--- tests/test_cancellation.py | 48 +++---- tests/test_directory_validation.py | 26 ++-- tests/test_session_ownership.py | 153 ++++++++-------------- 8 files changed, 168 insertions(+), 181 deletions(-) create mode 100644 src/opencode_a2a_serve/text_parts.py diff --git a/src/opencode_a2a_serve/jsonrpc_ext.py b/src/opencode_a2a_serve/jsonrpc_ext.py index 929f5fe..2afaca7 100644 --- a/src/opencode_a2a_serve/jsonrpc_ext.py +++ b/src/opencode_a2a_serve/jsonrpc_ext.py @@ -25,6 +25,7 @@ from starlette.responses import Response from .opencode_client import OpencodeClient +from .text_parts import extract_text_from_parts logger = logging.getLogger(__name__) @@ -119,18 +120,7 @@ def _as_a2a_message(session_id: str, item: Any) -> dict[str, Any] | None: text = item.get("text") if not isinstance(text, str): - # Best-effort extraction from OpenCode-like parts. - parts = item.get("parts") - if isinstance(parts, list): - texts: list[str] = [] - for part in parts: - if isinstance(part, dict) and part.get("type") == "text": - part_text = part.get("text") - if isinstance(part_text, str) and part_text: - texts.append(part_text) - text = "".join(texts).strip() - else: - text = "" + text = extract_text_from_parts(item.get("parts")) msg = Message( message_id=message_id, diff --git a/src/opencode_a2a_serve/opencode_client.py b/src/opencode_a2a_serve/opencode_client.py index 1ea3938..5b026d8 100644 --- a/src/opencode_a2a_serve/opencode_client.py +++ b/src/opencode_a2a_serve/opencode_client.py @@ -10,6 +10,7 @@ import httpx from .config import Settings +from .text_parts import extract_text_from_parts _UNSET = object() @@ -193,8 +194,7 @@ async def send_message( if self._log_payloads: logger = logging.getLogger(__name__) logger.debug("OpenCode response payload=%s", data) - parts = data.get("parts", []) - text_content = _extract_text(parts) + text_content = extract_text_from_parts(data.get("parts", [])) message_id = None info = data.get("info") if isinstance(info, dict): @@ -205,11 +205,3 @@ async def send_message( message_id=message_id, raw=data, ) - - -def _extract_text(parts: list[dict[str, Any]]) -> str: - texts: list[str] = [] - for part in parts: - if part.get("type") == "text" and isinstance(part.get("text"), str): - texts.append(part["text"]) - return "".join(texts).strip() diff --git a/src/opencode_a2a_serve/text_parts.py b/src/opencode_a2a_serve/text_parts.py new file mode 100644 index 0000000..70a641e --- /dev/null +++ b/src/opencode_a2a_serve/text_parts.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Any + + +def extract_text_from_parts(parts: Any) -> str: + if not isinstance(parts, list): + return "" + texts: list[str] = [] + for part in parts: + if isinstance(part, dict) and part.get("type") == "text": + part_text = part.get("text") + if isinstance(part_text, str): + texts.append(part_text) + return "".join(texts).strip() diff --git a/tests/helpers.py b/tests/helpers.py index 860fdff..0bcd1d3 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,10 @@ from __future__ import annotations from typing import Any +from unittest.mock import MagicMock, PropertyMock + +from a2a.server.agent_execution import RequestContext +from a2a.server.context import ServerCallContext from opencode_a2a_serve.config import Settings @@ -23,3 +27,47 @@ async def enqueue_event(self, event: Any) -> None: async def close(self) -> None: return None + + +def make_request_context_mock( + *, + task_id: str | None, + context_id: str | None, + identity: str | None = None, + user_input: str = "", + metadata: Any = None, + message: Any = None, + current_task: Any = None, + call_context_enabled: bool = True, +) -> MagicMock: + context = MagicMock(spec=RequestContext) + context.task_id = task_id + context.context_id = context_id + context.get_user_input.return_value = user_input + context.metadata = metadata + context.message = message + context.current_task = current_task + if call_context_enabled: + call_context = MagicMock(spec=ServerCallContext) + call_context.state = {"identity": identity} if identity else {} + context.call_context = call_context + else: + context.call_context = None + return context + + +def configure_mock_client_runtime( + client: Any, + *, + directory: str = "/tmp/workspace", + settings_overrides: dict[str, Any] | None = None, +) -> None: + overrides: dict[str, Any] = { + "a2a_bearer_token": "test", + "opencode_base_url": "http://localhost", + "a2a_allow_directory_override": True, + } + if settings_overrides: + overrides.update(settings_overrides) + type(client).directory = PropertyMock(return_value=directory) + type(client).settings = PropertyMock(return_value=make_settings(**overrides)) diff --git a/tests/test_agent_errors.py b/tests/test_agent_errors.py index 28c3fee..07bec00 100644 --- a/tests/test_agent_errors.py +++ b/tests/test_agent_errors.py @@ -1,10 +1,10 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from a2a.server.agent_execution import RequestContext from a2a.server.events.event_queue import EventQueue from opencode_a2a_serve.agent import OpencodeAgentExecutor +from tests.helpers import make_request_context_mock @pytest.mark.asyncio @@ -13,10 +13,11 @@ async def test_execute_missing_ids(): executor = OpencodeAgentExecutor(client, streaming_enabled=False) # Mock RequestContext with missing IDs - context = MagicMock(spec=RequestContext) - context.task_id = None - context.context_id = None - context.call_context = None + context = make_request_context_mock( + task_id=None, + context_id=None, + call_context_enabled=False, + ) event_queue = AsyncMock(spec=EventQueue) @@ -40,9 +41,10 @@ async def test_cancel_missing_ids(): executor = OpencodeAgentExecutor(client, streaming_enabled=False) # Mock RequestContext with missing IDs - context = MagicMock(spec=RequestContext) - context.task_id = None - context.context_id = None + context = make_request_context_mock( + task_id=None, + context_id=None, + ) event_queue = AsyncMock(spec=EventQueue) @@ -59,14 +61,13 @@ async def test_execute_invalid_metadata_type(): client = MagicMock() executor = OpencodeAgentExecutor(client, streaming_enabled=False) - context = MagicMock(spec=RequestContext) - context.task_id = "task-1" - context.context_id = "ctx-1" - context.call_context = None - context.get_user_input.return_value = "hello" - context.metadata = ["not-a-map"] - context.message = None - context.current_task = None + context = make_request_context_mock( + task_id="task-1", + context_id="ctx-1", + user_input="hello", + metadata=["not-a-map"], + call_context_enabled=False, + ) event_queue = AsyncMock(spec=EventQueue) await executor.execute(context, event_queue) diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index bbf4738..efed47f 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -1,15 +1,13 @@ import asyncio -from unittest.mock import AsyncMock, MagicMock, PropertyMock +from unittest.mock import AsyncMock, MagicMock import pytest -from a2a.server.agent_execution import RequestContext -from a2a.server.context import ServerCallContext from a2a.server.events.event_queue import EventQueue from a2a.types import TaskState, TaskStatusUpdateEvent from opencode_a2a_serve.agent import OpencodeAgentExecutor -from opencode_a2a_serve.config import Settings from opencode_a2a_serve.opencode_client import OpencodeClient +from tests.helpers import configure_mock_client_runtime, make_request_context_mock @pytest.mark.asyncio @@ -39,35 +37,26 @@ async def send_message( client.create_session.return_value = "session-1" client.send_message.side_effect = send_message - type(client).directory = PropertyMock(return_value="/tmp/workspace") - type(client).settings = PropertyMock( - return_value=Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", - A2A_ALLOW_DIRECTORY_OVERRIDE=True, - ) - ) + configure_mock_client_runtime(client) executor = OpencodeAgentExecutor(client, streaming_enabled=False) - execute_context = MagicMock(spec=RequestContext) - execute_context.task_id = "task-1" - execute_context.context_id = "context-A" - execute_context.call_context = MagicMock(spec=ServerCallContext) - execute_context.call_context.state = {"identity": "user-1"} - execute_context.get_user_input.return_value = "hello" - execute_context.current_task = None - execute_context.message = None - execute_context.metadata = None + execute_context = make_request_context_mock( + task_id="task-1", + context_id="context-A", + identity="user-1", + user_input="hello", + ) execute_queue = AsyncMock(spec=EventQueue) execute_task = asyncio.create_task(executor.execute(execute_context, execute_queue)) await asyncio.wait_for(send_started.wait(), timeout=1.0) - cancel_context = MagicMock(spec=RequestContext) - cancel_context.task_id = "task-1" - cancel_context.context_id = "context-A" - cancel_context.call_context = None + cancel_context = make_request_context_mock( + task_id="task-1", + context_id="context-A", + call_context_enabled=False, + ) cancel_queue = AsyncMock(spec=EventQueue) await asyncio.wait_for(executor.cancel(cancel_context, cancel_queue), timeout=1.0) @@ -92,10 +81,11 @@ async def send_message( @pytest.mark.asyncio async def test_cancel_does_not_block_with_real_event_queue() -> None: executor = OpencodeAgentExecutor(MagicMock(), streaming_enabled=False) - context = MagicMock(spec=RequestContext) - context.task_id = None - context.context_id = None - context.call_context = None + context = make_request_context_mock( + task_id=None, + context_id=None, + call_context_enabled=False, + ) queue = EventQueue() await asyncio.wait_for(executor.cancel(context, queue), timeout=0.5) diff --git a/tests/test_directory_validation.py b/tests/test_directory_validation.py index e3d10bf..2a17bdf 100644 --- a/tests/test_directory_validation.py +++ b/tests/test_directory_validation.py @@ -1,22 +1,21 @@ from pathlib import Path -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest -from a2a.server.agent_execution import RequestContext from a2a.server.events.event_queue import EventQueue from opencode_a2a_serve.agent import OpencodeAgentExecutor -from opencode_a2a_serve.config import Settings from opencode_a2a_serve.opencode_client import OpencodeClient +from tests.helpers import make_request_context_mock, make_settings @pytest.fixture def mock_client(): - settings = Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", - OPENCODE_DIRECTORY="/tmp/workspace", - A2A_ALLOW_DIRECTORY_OVERRIDE=True, + settings = make_settings( + a2a_bearer_token="test", + opencode_base_url="http://localhost", + opencode_directory="/tmp/workspace", + a2a_allow_directory_override=True, ) client = OpencodeClient(settings) @@ -76,11 +75,12 @@ async def test_execute_with_invalid_directory(mock_client): executor = OpencodeAgentExecutor(mock_client, streaming_enabled=False) event_queue = AsyncMock(spec=EventQueue) - context = MagicMock(spec=RequestContext) - context.task_id = "task-1" - context.context_id = "ctx-1" - context.metadata = {"directory": "/etc"} # Illegal - context.call_context = None + context = make_request_context_mock( + task_id="task-1", + context_id="ctx-1", + metadata={"directory": "/etc"}, # Illegal + call_context_enabled=False, + ) await executor.execute(context, event_queue) diff --git a/tests/test_session_ownership.py b/tests/test_session_ownership.py index d5cd227..825a72a 100644 --- a/tests/test_session_ownership.py +++ b/tests/test_session_ownership.py @@ -1,14 +1,12 @@ import asyncio -from unittest.mock import AsyncMock, MagicMock, PropertyMock +from unittest.mock import AsyncMock, MagicMock import pytest -from a2a.server.agent_execution import RequestContext -from a2a.server.context import ServerCallContext from a2a.server.events.event_queue import EventQueue from opencode_a2a_serve.agent import OpencodeAgentExecutor, _TTLCache -from opencode_a2a_serve.config import Settings from opencode_a2a_serve.opencode_client import OpencodeClient +from tests.helpers import configure_mock_client_runtime, make_request_context_mock @pytest.fixture @@ -32,15 +30,7 @@ async def side_effect(title=None, directory=None): response.message_id = "msg-1" client.send_message.return_value = response - # Use PropertyMock for properties - type(client).directory = PropertyMock(return_value="/tmp/workspace") - type(client).settings = PropertyMock( - return_value=Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", - A2A_ALLOW_DIRECTORY_OVERRIDE=True, - ) - ) + configure_mock_client_runtime(client) return client @@ -50,30 +40,24 @@ async def test_identity_isolation(mock_client): event_queue = AsyncMock(spec=EventQueue) # User 1, Context A - context1 = MagicMock(spec=RequestContext) - context1.task_id = "task-1" - context1.context_id = "context-A" - context1.call_context = MagicMock(spec=ServerCallContext) - context1.call_context.state = {"identity": "user-1"} - context1.get_user_input.return_value = "hello" - context1.current_task = None - context1.message = None - context1.metadata = None + context1 = make_request_context_mock( + task_id="task-1", + context_id="context-A", + identity="user-1", + user_input="hello", + ) await executor.execute(context1, event_queue) mock_client.create_session.assert_called_once() assert executor._sessions.get(("user-1", "context-A")) == "session-1" # User 2, Context A (Same context ID, different user) - context2 = MagicMock(spec=RequestContext) - context2.task_id = "task-2" - context2.context_id = "context-A" - context2.call_context = MagicMock(spec=ServerCallContext) - context2.call_context.state = {"identity": "user-2"} - context2.get_user_input.return_value = "hello" - context2.current_task = None - context2.message = None - context2.metadata = None + context2 = make_request_context_mock( + task_id="task-2", + context_id="context-A", + identity="user-2", + user_input="hello", + ) await executor.execute(context2, event_queue) # Should create a NEW session for user-2 @@ -89,28 +73,24 @@ async def test_session_hijack_prevention(mock_client): event_queue = AsyncMock(spec=EventQueue) # User 1 creates session-1 - context1 = MagicMock(spec=RequestContext) - context1.task_id = "task-1" - context1.context_id = "context-A" - context1.call_context = MagicMock(spec=ServerCallContext) - context1.call_context.state = {"identity": "user-1"} - context1.get_user_input.return_value = "hello" - context1.current_task = None - context1.message = None - context1.metadata = None + context1 = make_request_context_mock( + task_id="task-1", + context_id="context-A", + identity="user-1", + user_input="hello", + ) await executor.execute(context1, event_queue) assert executor._session_owners.get("session-1") == "user-1" # User 2 tries to bind to session-1 via metadata - context2 = MagicMock(spec=RequestContext) - context2.task_id = "task-2" - context2.context_id = "context-B" - context2.call_context = MagicMock(spec=ServerCallContext) - context2.call_context.state = {"identity": "user-2"} - context2.get_user_input.return_value = "hello" - context2.metadata = {"opencode_session_id": "session-1"} - context2.message = None + context2 = make_request_context_mock( + task_id="task-2", + context_id="context-B", + identity="user-2", + user_input="hello", + metadata={"opencode_session_id": "session-1"}, + ) # This should fail and emit an error await executor.execute(context2, event_queue) @@ -159,30 +139,19 @@ async def send_message( client.create_session.side_effect = create_session client.send_message.side_effect = send_message - type(client).directory = PropertyMock(return_value="/tmp/workspace") - type(client).settings = PropertyMock( - return_value=Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", - A2A_ALLOW_DIRECTORY_OVERRIDE=True, - ) - ) + configure_mock_client_runtime(client) executor = OpencodeAgentExecutor(client, streaming_enabled=False) event_queue_1 = AsyncMock(spec=EventQueue) event_queue_2 = AsyncMock(spec=EventQueue) - def _context(task_id: str, identity: str) -> RequestContext: - context = MagicMock(spec=RequestContext) - context.task_id = task_id - context.context_id = "context-A" - context.call_context = MagicMock(spec=ServerCallContext) - context.call_context.state = {"identity": identity} - context.get_user_input.return_value = "hello" - context.current_task = None - context.message = None - context.metadata = None - return context + def _context(task_id: str, identity: str): + return make_request_context_mock( + task_id=task_id, + context_id="context-A", + identity=identity, + user_input="hello", + ) await asyncio.gather( executor.execute(_context("task-1", "user-1"), event_queue_1), @@ -263,27 +232,18 @@ async def send_message( raise RuntimeError(f"upstream failed for {session_id}") client.send_message.side_effect = send_message - type(client).directory = PropertyMock(return_value="/tmp/workspace") - type(client).settings = PropertyMock( - return_value=Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", - A2A_ALLOW_DIRECTORY_OVERRIDE=True, - ) - ) + configure_mock_client_runtime(client) executor = OpencodeAgentExecutor(client, streaming_enabled=False) event_queue = AsyncMock(spec=EventQueue) - context = MagicMock(spec=RequestContext) - context.task_id = "task-1" - context.context_id = "context-A" - context.call_context = MagicMock(spec=ServerCallContext) - context.call_context.state = {"identity": "user-1"} - context.get_user_input.return_value = "hello" - context.metadata = {"opencode_session_id": "session-X"} - context.current_task = None - context.message = None + context = make_request_context_mock( + task_id="task-1", + context_id="context-A", + identity="user-1", + user_input="hello", + metadata={"opencode_session_id": "session-X"}, + ) await executor.execute(context, event_queue) @@ -305,27 +265,18 @@ async def send_message( raise asyncio.CancelledError() client.send_message.side_effect = send_message - type(client).directory = PropertyMock(return_value="/tmp/workspace") - type(client).settings = PropertyMock( - return_value=Settings( - A2A_BEARER_TOKEN="test", - OPENCODE_BASE_URL="http://localhost", - A2A_ALLOW_DIRECTORY_OVERRIDE=True, - ) - ) + configure_mock_client_runtime(client) executor = OpencodeAgentExecutor(client, streaming_enabled=False) event_queue = AsyncMock(spec=EventQueue) - context = MagicMock(spec=RequestContext) - context.task_id = "task-1" - context.context_id = "context-A" - context.call_context = MagicMock(spec=ServerCallContext) - context.call_context.state = {"identity": "user-1"} - context.get_user_input.return_value = "hello" - context.metadata = {"opencode_session_id": "session-X"} - context.current_task = None - context.message = None + context = make_request_context_mock( + task_id="task-1", + context_id="context-A", + identity="user-1", + user_input="hello", + metadata={"opencode_session_id": "session-X"}, + ) with pytest.raises(asyncio.CancelledError): await executor.execute(context, event_queue) From 5edfe3e93a11f4feb96b38fc615c5765a9b060f4 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 02:35:45 -0500 Subject: [PATCH 3/8] =?UTF-8?q?[chore]=20=E7=BB=A7=E7=BB=AD=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E6=B5=8B=E8=AF=95=E9=87=8D=E5=A4=8D=E6=A0=B7=E6=9D=BF?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/helpers.py | 68 ++++++++++++++++++++ tests/test_opencode_agent_session_binding.py | 46 ++----------- tests/test_opencode_client_params.py | 20 +++--- tests/test_opencode_session_extension.py | 21 +----- tests/test_transport_contract.py | 48 +------------- 5 files changed, 85 insertions(+), 118 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 0bcd1d3..2eeb1b7 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,6 +7,7 @@ from a2a.server.context import ServerCallContext from opencode_a2a_serve.config import Settings +from opencode_a2a_serve.opencode_client import OpencodeMessage def make_settings(**overrides: Any) -> Settings: @@ -71,3 +72,70 @@ def configure_mock_client_runtime( overrides.update(settings_overrides) type(client).directory = PropertyMock(return_value=directory) type(client).settings = PropertyMock(return_value=make_settings(**overrides)) + + +class DummyChatOpencodeClient: + def __init__(self, settings: Settings | None = None) -> None: + self.created_sessions = 0 + self.sent_session_ids: list[str] = [] + self.stream_timeout = None + self.directory = None + self.settings = settings or make_settings( + a2a_bearer_token="test", + opencode_base_url="http://localhost", + ) + + async def close(self) -> None: + return None + + async def create_session( + self, + title: str | None = None, + *, + directory: str | None = None, + ) -> str: + del title, directory + self.created_sessions += 1 + return f"ses-created-{self.created_sessions}" + + async def send_message( + self, + session_id: str, + text: str, + *, + directory: str | None = None, + timeout_override=None, # noqa: ANN001 + ) -> OpencodeMessage: + del directory, timeout_override + self.sent_session_ids.append(session_id) + return OpencodeMessage( + text=f"echo:{text}", + session_id=session_id, + message_id="m-1", + raw={}, + ) + + async def stream_events(self, stop_event=None, *, directory: str | None = None): # noqa: ANN001 + del stop_event, directory + for _ in (): + yield {} + + +class DummySessionQueryOpencodeClient: + def __init__(self, _settings: Settings) -> None: + self._sessions_payload = {"items": [{"id": "s-1"}]} + self._messages_payload = {"items": [{"id": "m-1", "text": "SECRET_HISTORY"}]} + self.last_sessions_params = None + self.last_messages_params = None + + async def close(self) -> None: + return None + + async def list_sessions(self, *, params=None): + self.last_sessions_params = params + return self._sessions_payload + + async def list_messages(self, session_id: str, *, params=None): + assert session_id + self.last_messages_params = params + return self._messages_payload diff --git a/tests/test_opencode_agent_session_binding.py b/tests/test_opencode_agent_session_binding.py index 989d397..b1c858f 100644 --- a/tests/test_opencode_agent_session_binding.py +++ b/tests/test_opencode_agent_session_binding.py @@ -5,45 +5,7 @@ from a2a.types import Message, MessageSendParams, Role, TextPart from opencode_a2a_serve.agent import OpencodeAgentExecutor -from opencode_a2a_serve.opencode_client import OpencodeMessage -from tests.helpers import DummyEventQueue, make_settings - - -class DummyOpencodeClient: - def __init__(self) -> None: - self.created_sessions = 0 - self.sent_session_ids: list[str] = [] - self.stream_timeout = None - self.directory = None - self.settings = make_settings( - a2a_bearer_token="test", - opencode_base_url="http://localhost", - ) - - async def create_session( - self, - title: str | None = None, - *, - directory: str | None = None, - ) -> str: - self.created_sessions += 1 - return f"ses-created-{self.created_sessions}" - - async def send_message( - self, session_id: str, text: str, *, directory: str | None = None, timeout_override=None - ) -> OpencodeMessage: # noqa: ANN001 - self.sent_session_ids.append(session_id) - return OpencodeMessage( - text=f"echo:{text}", - session_id=session_id, - message_id="m-1", - raw={}, - ) - - async def stream_events(self, stop_event=None, *, directory: str | None = None): # noqa: ANN001 - del stop_event, directory - for _ in (): - yield {} +from tests.helpers import DummyChatOpencodeClient, DummyEventQueue def _context(*, task_id: str, context_id: str, text: str, metadata: dict | None) -> RequestContext: @@ -58,7 +20,7 @@ def _context(*, task_id: str, context_id: str, text: str, metadata: dict | None) @pytest.mark.asyncio async def test_agent_prefers_metadata_opencode_session_id() -> None: - client = DummyOpencodeClient() + client = DummyChatOpencodeClient() executor = OpencodeAgentExecutor(client, streaming_enabled=False) q = DummyEventQueue() @@ -76,7 +38,7 @@ async def test_agent_prefers_metadata_opencode_session_id() -> None: @pytest.mark.asyncio async def test_agent_caches_bound_session_id_for_followup_requests() -> None: - client = DummyOpencodeClient() + client = DummyChatOpencodeClient() executor = OpencodeAgentExecutor( client, streaming_enabled=False, @@ -107,7 +69,7 @@ async def test_agent_caches_bound_session_id_for_followup_requests() -> None: @pytest.mark.asyncio async def test_agent_dedupes_concurrent_session_creates_per_context() -> None: - class SlowCreateClient(DummyOpencodeClient): + class SlowCreateClient(DummyChatOpencodeClient): async def create_session( self, title: str | None = None, diff --git a/tests/test_opencode_client_params.py b/tests/test_opencode_client_params.py index 2bf74c5..1d77162 100644 --- a/tests/test_opencode_client_params.py +++ b/tests/test_opencode_client_params.py @@ -4,16 +4,6 @@ from tests.helpers import make_settings -def _settings(*, directory: str | None): - return make_settings( - a2a_bearer_token="t-1", - opencode_directory=directory, - opencode_timeout=1.0, - a2a_log_level="DEBUG", - a2a_log_payloads=False, - ) - - class _DummyResponse: def raise_for_status(self) -> None: return None @@ -24,7 +14,15 @@ def json(self): @pytest.mark.asyncio async def test_merge_params_does_not_allow_directory_override(monkeypatch): - client = OpencodeClient(_settings(directory="/safe")) + client = OpencodeClient( + make_settings( + a2a_bearer_token="t-1", + opencode_directory="/safe", + opencode_timeout=1.0, + a2a_log_level="DEBUG", + a2a_log_payloads=False, + ) + ) seen = {} diff --git a/tests/test_opencode_session_extension.py b/tests/test_opencode_session_extension.py index 9df2c9d..fc9326e 100644 --- a/tests/test_opencode_session_extension.py +++ b/tests/test_opencode_session_extension.py @@ -4,29 +4,10 @@ import pytest from opencode_a2a_serve.config import Settings +from tests.helpers import DummySessionQueryOpencodeClient as DummyOpencodeClient from tests.helpers import make_settings -class DummyOpencodeClient: - def __init__(self, _settings: Settings) -> None: - self._sessions_payload = {"items": [{"id": "s-1"}]} - self._messages_payload = {"items": [{"id": "m-1", "text": "SECRET_HISTORY"}]} - self.last_sessions_params = None - self.last_messages_params = None - - async def close(self) -> None: - return None - - async def list_sessions(self, *, params=None): - self.last_sessions_params = params - return self._sessions_payload - - async def list_messages(self, session_id: str, *, params=None): - assert session_id - self.last_messages_params = params - return self._messages_payload - - def _settings(*, token: str, log_payloads: bool) -> Settings: return make_settings( a2a_bearer_token=token, diff --git a/tests/test_transport_contract.py b/tests/test_transport_contract.py index 77f3e46..70cb95d 100644 --- a/tests/test_transport_contract.py +++ b/tests/test_transport_contract.py @@ -3,9 +3,7 @@ from a2a.types import TransportProtocol from opencode_a2a_serve.app import build_agent_card, create_app -from opencode_a2a_serve.config import Settings -from opencode_a2a_serve.opencode_client import OpencodeMessage -from tests.helpers import make_settings +from tests.helpers import DummyChatOpencodeClient, make_settings def test_agent_card_declares_dual_stack_with_http_json_preferred() -> None: @@ -25,51 +23,11 @@ def test_rest_subscription_route_matches_current_sdk_contract() -> None: assert "/v1/tasks/{id}:resubscribe" not in route_paths -class DummyOpencodeClient: - def __init__(self, settings: Settings) -> None: - self.settings = settings - self.directory = None - self.stream_timeout = None - - async def close(self) -> None: - return None - - async def create_session( - self, - title: str | None = None, - *, - directory: str | None = None, - ) -> str: - del title, directory - return "ses-1" - - async def send_message( - self, - session_id: str, - text: str, - *, - directory: str | None = None, - timeout_override=None, # noqa: ANN001 - ) -> OpencodeMessage: - del directory, timeout_override - return OpencodeMessage( - text=f"echo:{text}", - session_id=session_id, - message_id="m-1", - raw={}, - ) - - async def stream_events(self, stop_event=None, *, directory: str | None = None): # noqa: ANN001 - del stop_event, directory - for _ in (): - yield {} - - @pytest.mark.asyncio async def test_dual_stack_send_accepts_transport_native_payloads(monkeypatch) -> None: import opencode_a2a_serve.app as app_module - monkeypatch.setattr(app_module, "OpencodeClient", DummyOpencodeClient) + monkeypatch.setattr(app_module, "OpencodeClient", DummyChatOpencodeClient) app = app_module.create_app(make_settings(a2a_bearer_token="test-token")) transport = httpx.ASGITransport(app=app) headers = {"Authorization": "Bearer test-token"} @@ -107,7 +65,7 @@ async def test_dual_stack_send_accepts_transport_native_payloads(monkeypatch) -> async def test_dual_stack_send_rejects_cross_transport_payload_shapes(monkeypatch) -> None: import opencode_a2a_serve.app as app_module - monkeypatch.setattr(app_module, "OpencodeClient", DummyOpencodeClient) + monkeypatch.setattr(app_module, "OpencodeClient", DummyChatOpencodeClient) app = app_module.create_app(make_settings(a2a_bearer_token="test-token")) transport = httpx.ASGITransport(app=app) headers = {"Authorization": "Bearer test-token"} From 5a8a2a98fe545754e1d5582201630b849d6fd169 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 02:39:21 -0500 Subject: [PATCH 4/8] =?UTF-8?q?[chore]=20=E7=BB=A7=E7=BB=AD=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20RequestContext=20=E6=9E=84=E9=80=A0=E4=B8=8E?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=87=8D=E5=A4=8D=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/helpers.py | 18 ++++++ tests/test_opencode_agent_session_binding.py | 22 ++----- tests/test_opencode_session_extension.py | 68 +++++++++++++------- tests/test_streaming_output_contract.py | 61 +++++++++--------- 4 files changed, 101 insertions(+), 68 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 2eeb1b7..a665fd6 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,6 +5,7 @@ from a2a.server.agent_execution import RequestContext from a2a.server.context import ServerCallContext +from a2a.types import Message, MessageSendParams, Role, TextPart from opencode_a2a_serve.config import Settings from opencode_a2a_serve.opencode_client import OpencodeMessage @@ -74,6 +75,23 @@ def configure_mock_client_runtime( type(client).settings = PropertyMock(return_value=make_settings(**overrides)) +def make_request_context( + *, + task_id: str, + context_id: str, + text: str, + metadata: dict[str, Any] | None = None, + message_id: str = "req-1", +) -> RequestContext: + message = Message( + message_id=message_id, + role=Role.user, + parts=[TextPart(text=text)], + ) + params = MessageSendParams(message=message, metadata=metadata) + return RequestContext(request=params, task_id=task_id, context_id=context_id) + + class DummyChatOpencodeClient: def __init__(self, settings: Settings | None = None) -> None: self.created_sessions = 0 diff --git a/tests/test_opencode_agent_session_binding.py b/tests/test_opencode_agent_session_binding.py index b1c858f..d36783c 100644 --- a/tests/test_opencode_agent_session_binding.py +++ b/tests/test_opencode_agent_session_binding.py @@ -1,21 +1,9 @@ import asyncio import pytest -from a2a.server.agent_execution import RequestContext -from a2a.types import Message, MessageSendParams, Role, TextPart from opencode_a2a_serve.agent import OpencodeAgentExecutor -from tests.helpers import DummyChatOpencodeClient, DummyEventQueue - - -def _context(*, task_id: str, context_id: str, text: str, metadata: dict | None) -> RequestContext: - msg = Message( - message_id="msg-1", - role=Role.user, - parts=[TextPart(text=text)], - ) - params = MessageSendParams(message=msg, metadata=metadata) - return RequestContext(request=params, task_id=task_id, context_id=context_id) +from tests.helpers import DummyChatOpencodeClient, DummyEventQueue, make_request_context @pytest.mark.asyncio @@ -24,7 +12,7 @@ async def test_agent_prefers_metadata_opencode_session_id() -> None: executor = OpencodeAgentExecutor(client, streaming_enabled=False) q = DummyEventQueue() - ctx = _context( + ctx = make_request_context( task_id="t-1", context_id="c-1", text="hello", @@ -47,7 +35,7 @@ async def test_agent_caches_bound_session_id_for_followup_requests() -> None: ) q = DummyEventQueue() - ctx1 = _context( + ctx1 = make_request_context( task_id="t-1", context_id="c-1", text="hello", @@ -55,7 +43,7 @@ async def test_agent_caches_bound_session_id_for_followup_requests() -> None: ) await executor.execute(ctx1, q) - ctx2 = _context( + ctx2 = make_request_context( task_id="t-2", context_id="c-1", text="follow", @@ -89,7 +77,7 @@ async def create_session( async def run_one(task_id: str) -> None: q = DummyEventQueue() - ctx = _context(task_id=task_id, context_id="c-1", text="hi", metadata=None) + ctx = make_request_context(task_id=task_id, context_id="c-1", text="hi", metadata=None) await executor.execute(ctx, q) await asyncio.gather(run_one("t-1"), run_one("t-2"), run_one("t-3")) diff --git a/tests/test_opencode_session_extension.py b/tests/test_opencode_session_extension.py index fc9326e..4406e89 100644 --- a/tests/test_opencode_session_extension.py +++ b/tests/test_opencode_session_extension.py @@ -7,14 +7,10 @@ from tests.helpers import DummySessionQueryOpencodeClient as DummyOpencodeClient from tests.helpers import make_settings - -def _settings(*, token: str, log_payloads: bool) -> Settings: - return make_settings( - a2a_bearer_token=token, - a2a_log_payloads=log_payloads, - opencode_timeout=1.0, - a2a_log_level="DEBUG", - ) +_BASE_SETTINGS = { + "opencode_timeout": 1.0, + "a2a_log_level": "DEBUG", +} @pytest.mark.asyncio @@ -22,7 +18,9 @@ async def test_session_query_extension_requires_bearer_token(monkeypatch): import opencode_a2a_serve.app as app_module monkeypatch.setattr(app_module, "OpencodeClient", DummyOpencodeClient) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -48,9 +46,13 @@ async def test_session_query_extension_requires_bearer_token(monkeypatch): async def test_session_query_extension_returns_jsonrpc_result(monkeypatch): import opencode_a2a_serve.app as app_module - dummy = DummyOpencodeClient(_settings(token="t-1", log_payloads=False)) + dummy = DummyOpencodeClient( + make_settings(a2a_bearer_token="t-1", a2a_log_payloads=False, **_BASE_SETTINGS) + ) monkeypatch.setattr(app_module, "OpencodeClient", lambda _settings: dummy) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -113,7 +115,9 @@ def __init__(self, _settings: Settings) -> None: self._sessions_payload = {"foo": "bar"} # no items monkeypatch.setattr(app_module, "OpencodeClient", WeirdPayloadClient) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -143,7 +147,9 @@ def __init__(self, _settings: Settings) -> None: self._sessions_payload = {"items": [{"id": "s-1", "title": "My Session"}]} monkeypatch.setattr(app_module, "OpencodeClient", TitlePayloadClient) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -176,7 +182,9 @@ def __init__(self, _settings: Settings) -> None: } monkeypatch.setattr(app_module, "OpencodeClient", InfoRoleClient) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -209,7 +217,9 @@ def __init__(self, _settings: Settings) -> None: self._messages_payload = [{"id": "m-1", "text": "SECRET_HISTORY"}] monkeypatch.setattr(app_module, "OpencodeClient", ListPayloadClient) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -248,7 +258,9 @@ def __init__(self, _settings: Settings) -> None: self._messages_payload = {"messages": [{"id": "m-1", "text": "SECRET_HISTORY"}]} monkeypatch.setattr(app_module, "OpencodeClient", AltKeyPayloadClient) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -280,9 +292,13 @@ def __init__(self, _settings: Settings) -> None: async def test_session_query_extension_rejects_cursor_limit(monkeypatch): import opencode_a2a_serve.app as app_module - dummy = DummyOpencodeClient(_settings(token="t-1", log_payloads=False)) + dummy = DummyOpencodeClient( + make_settings(a2a_bearer_token="t-1", a2a_log_payloads=False, **_BASE_SETTINGS) + ) monkeypatch.setattr(app_module, "OpencodeClient", lambda _settings: dummy) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -308,9 +324,13 @@ async def test_session_query_extension_rejects_cursor_limit(monkeypatch): async def test_session_query_extension_rejects_size_over_max(monkeypatch): import opencode_a2a_serve.app as app_module - dummy = DummyOpencodeClient(_settings(token="t-1", log_payloads=False)) + dummy = DummyOpencodeClient( + make_settings(a2a_bearer_token="t-1", a2a_log_payloads=False, **_BASE_SETTINGS) + ) monkeypatch.setattr(app_module, "OpencodeClient", lambda _settings: dummy) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -342,7 +362,9 @@ async def list_messages(self, session_id: str, *, params=None): raise httpx.HTTPStatusError("Not Found", request=request, response=response) monkeypatch.setattr(app_module, "OpencodeClient", NotFoundOpencodeClient) - app = app_module.create_app(_settings(token="t-1", log_payloads=False)) + 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: @@ -371,7 +393,9 @@ async def test_session_query_extension_does_not_log_response_bodies(monkeypatch, monkeypatch.setattr(app_module, "OpencodeClient", DummyOpencodeClient) caplog.set_level(logging.DEBUG, logger="opencode_a2a_serve.app") - app = app_module.create_app(_settings(token="t-1", log_payloads=True)) + app = app_module.create_app( + make_settings(a2a_bearer_token="t-1", a2a_log_payloads=True, **_BASE_SETTINGS) + ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: diff --git a/tests/test_streaming_output_contract.py b/tests/test_streaming_output_contract.py index 9e88b03..17e5371 100644 --- a/tests/test_streaming_output_contract.py +++ b/tests/test_streaming_output_contract.py @@ -1,12 +1,11 @@ import asyncio import pytest -from a2a.server.agent_execution import RequestContext -from a2a.types import Message, MessageSendParams, Role, TaskArtifactUpdateEvent, TextPart +from a2a.types import TaskArtifactUpdateEvent from opencode_a2a_serve.agent import OpencodeAgentExecutor from opencode_a2a_serve.opencode_client import OpencodeMessage -from tests.helpers import DummyEventQueue, make_settings +from tests.helpers import DummyEventQueue, make_request_context, make_settings class DummyStreamingClient: @@ -69,18 +68,6 @@ async def stream_events(self, stop_event=None, *, directory: str | None = None): yield event -def _context( - *, task_id: str, context_id: str, text: str, metadata: dict | None = None -) -> RequestContext: - message = Message( - message_id="req-1", - role=Role.user, - parts=[TextPart(text=text)], - ) - params = MessageSendParams(message=message, metadata=metadata) - return RequestContext(request=params, task_id=task_id, context_id=context_id) - - def _event( *, session_id: str, @@ -166,7 +153,9 @@ async def test_streaming_filters_user_echo_and_emits_single_artifact_block_types executor._should_stream = lambda context: True # type: ignore[method-assign] queue = DummyEventQueue() - await executor.execute(_context(task_id="task-1", context_id="ctx-1", text=user_text), queue) + await executor.execute( + make_request_context(task_id="task-1", context_id="ctx-1", text=user_text), queue + ) updates = _artifact_updates(queue) assert updates @@ -197,7 +186,9 @@ async def test_streaming_does_not_send_duplicate_final_snapshot_when_chunks_exis executor._should_stream = lambda context: True # type: ignore[method-assign] queue = DummyEventQueue() - await executor.execute(_context(task_id="task-2", context_id="ctx-2", text="hi"), queue) + await executor.execute( + make_request_context(task_id="task-2", context_id="ctx-2", text="hi"), queue + ) final_updates = [ event @@ -221,7 +212,9 @@ async def test_streaming_emits_final_snapshot_only_when_stream_has_no_final_answ executor._should_stream = lambda context: True # type: ignore[method-assign] queue = DummyEventQueue() - await executor.execute(_context(task_id="task-3", context_id="ctx-3", text="hello"), queue) + await executor.execute( + make_request_context(task_id="task-3", context_id="ctx-3", text="hello"), queue + ) final_updates = [ event @@ -250,10 +243,16 @@ async def test_execute_serializes_send_message_per_session() -> None: await asyncio.gather( executor.execute( - _context(task_id="task-4", context_id="ctx-4", text="hello", metadata=metadata), queue_1 + make_request_context( + task_id="task-4", context_id="ctx-4", text="hello", metadata=metadata + ), + queue_1, ), executor.execute( - _context(task_id="task-5", context_id="ctx-5", text="world", metadata=metadata), queue_2 + make_request_context( + task_id="task-5", context_id="ctx-5", text="world", metadata=metadata + ), + queue_2, ), ) @@ -278,7 +277,9 @@ async def test_streaming_drops_events_without_message_id_and_falls_back_to_snaps executor._should_stream = lambda context: True # type: ignore[method-assign] queue = DummyEventQueue() - await executor.execute(_context(task_id="task-6", context_id="ctx-6", text="hello"), queue) + await executor.execute( + make_request_context(task_id="task-6", context_id="ctx-6", text="hello"), queue + ) updates = _artifact_updates(queue) assert len(updates) == 1 @@ -321,7 +322,7 @@ async def test_streaming_treats_embedded_markers_as_plain_text_without_typed_par queue = DummyEventQueue() await executor.execute( - _context(task_id="task-embedded", context_id="ctx-embedded", text="go"), queue + make_request_context(task_id="task-embedded", context_id="ctx-embedded", text="go"), queue ) updates = _artifact_updates(queue) @@ -389,7 +390,7 @@ async def test_streaming_emits_structured_tool_part_updates() -> None: queue = DummyEventQueue() await executor.execute( - _context(task_id="task-tool-bracket", context_id="ctx-tool-bracket", text="go"), + make_request_context(task_id="task-tool-bracket", context_id="ctx-tool-bracket", text="go"), queue, ) @@ -417,7 +418,7 @@ async def test_streaming_flushes_partial_marker_on_eof_as_current_block_type() - queue = DummyEventQueue() await executor.execute( - _context(task_id="task-eof-flush", context_id="ctx-eof-flush", text="go"), + make_request_context(task_id="task-eof-flush", context_id="ctx-eof-flush", text="go"), queue, ) @@ -465,7 +466,7 @@ async def test_streaming_never_resets_single_artifact_after_first_chunk() -> Non queue = DummyEventQueue() await executor.execute( - _context(task_id="task-no-reset", context_id="ctx-no-reset", text="go"), + make_request_context(task_id="task-no-reset", context_id="ctx-no-reset", text="go"), queue, ) @@ -510,7 +511,7 @@ async def test_streaming_suppresses_reasoning_snapshot_reset_after_delta() -> No queue = DummyEventQueue() await executor.execute( - _context(task_id="task-reason-reset", context_id="ctx-reason-reset", text="go"), + make_request_context(task_id="task-reason-reset", context_id="ctx-reason-reset", text="go"), queue, ) @@ -545,7 +546,7 @@ async def test_streaming_supports_message_part_delta_events() -> None: queue = DummyEventQueue() await executor.execute( - _context(task_id="task-delta", context_id="ctx-delta", text="go"), + make_request_context(task_id="task-delta", context_id="ctx-delta", text="go"), queue, ) @@ -581,7 +582,9 @@ async def test_streaming_buffers_delta_until_part_updated_arrives() -> None: queue = DummyEventQueue() await executor.execute( - _context(task_id="task-buffered-delta", context_id="ctx-buffered-delta", text="go"), + make_request_context( + task_id="task-buffered-delta", context_id="ctx-buffered-delta", text="go" + ), queue, ) @@ -624,7 +627,7 @@ async def test_streaming_keeps_multiple_message_ids_in_same_request_window() -> queue = DummyEventQueue() await executor.execute( - _context(task_id="task-multi-mid", context_id="ctx-multi-mid", text="go"), + make_request_context(task_id="task-multi-mid", context_id="ctx-multi-mid", text="go"), queue, ) From d0057a86ccfc2417bd4d68413c44a91be6ef9b6b Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 02:40:11 -0500 Subject: [PATCH 5/8] =?UTF-8?q?[chore]=20=E8=BF=9B=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E6=94=B6=E6=95=9B=20session=20ownership=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A0=B7=E6=9D=BF=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_session_ownership.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/test_session_ownership.py b/tests/test_session_ownership.py index 825a72a..7499771 100644 --- a/tests/test_session_ownership.py +++ b/tests/test_session_ownership.py @@ -145,17 +145,25 @@ async def send_message( event_queue_1 = AsyncMock(spec=EventQueue) event_queue_2 = AsyncMock(spec=EventQueue) - def _context(task_id: str, identity: str): - return make_request_context_mock( - task_id=task_id, - context_id="context-A", - identity=identity, - user_input="hello", - ) - await asyncio.gather( - executor.execute(_context("task-1", "user-1"), event_queue_1), - executor.execute(_context("task-2", "user-2"), event_queue_2), + executor.execute( + make_request_context_mock( + task_id="task-1", + context_id="context-A", + identity="user-1", + user_input="hello", + ), + event_queue_1, + ), + executor.execute( + make_request_context_mock( + task_id="task-2", + context_id="context-A", + identity="user-2", + user_input="hello", + ), + event_queue_2, + ), ) assert client.create_session.call_count == 2 From e787aabcde3a122501eaac10b8776b826f57ef1c Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 02:56:04 -0500 Subject: [PATCH 6/8] =?UTF-8?q?[chore]=20=E7=A7=BB=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E6=AD=BB=E4=BB=A3=E7=A0=81=E4=B8=8E=E6=97=A0?= =?UTF-8?q?=E6=95=88=E5=8F=82=E6=95=B0=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/opencode_a2a_serve/agent.py | 6 +----- src/opencode_a2a_serve/jsonrpc_ext.py | 6 ++---- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/opencode_a2a_serve/agent.py b/src/opencode_a2a_serve/agent.py index 699e744..e500418 100644 --- a/src/opencode_a2a_serve/agent.py +++ b/src/opencode_a2a_serve/agent.py @@ -69,17 +69,13 @@ class _StreamPartState: @dataclass class _StreamOutputState: user_text: str - observed_message_ids: set[str] = field(default_factory=set) content_buffers: dict[BlockType, str] = field(default_factory=dict) saw_any_chunk: bool = False emitted_stream_chunk: bool = False sequence: int = 0 def matches_expected_message(self, message_id: str | None) -> bool: - if not message_id: - return False - self.observed_message_ids.add(message_id) - return True + return bool(message_id) def should_drop_initial_user_echo( self, diff --git a/src/opencode_a2a_serve/jsonrpc_ext.py b/src/opencode_a2a_serve/jsonrpc_ext.py index 2afaca7..b2f90f4 100644 --- a/src/opencode_a2a_serve/jsonrpc_ext.py +++ b/src/opencode_a2a_serve/jsonrpc_ext.py @@ -57,7 +57,7 @@ def _parse_positive_int(value: Any, *, field: str) -> int | None: UNTITLED_SESSION_TITLE = "Untitled session" -def _extract_session_title(session: dict[str, Any], *, session_id: str) -> str: +def _extract_session_title(session: dict[str, Any]) -> str: candidates: list[Any] = [ session.get("title"), session.get("name"), @@ -78,8 +78,6 @@ def _extract_session_title(session: dict[str, Any], *, session_id: str) -> str: return value.strip() # Stable placeholder so downstream can always render a label. - # Downstream can still fall back to session_id if they prefer. - _ = session_id return UNTITLED_SESSION_TITLE @@ -90,7 +88,7 @@ def _as_a2a_session_task(session: Any) -> dict[str, Any] | None: if not isinstance(raw_id, str) or not raw_id.strip(): return None session_id = raw_id.strip() - title = _extract_session_title(session, session_id=session_id) + title = _extract_session_title(session) task = Task( id=session_id, context_id=session_id, From 6f486240e017ee056dc7b2793fa135600934ccfe Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 02:57:46 -0500 Subject: [PATCH 7/8] =?UTF-8?q?[feat]=20=E6=8F=90=E4=BE=9B=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=B6=88=E6=81=AF=E7=BA=A7=E4=B8=8E=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=BA=A7=20ID=20#88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/opencode_a2a_serve/agent.py | 44 ++++++++++++++++---- tests/test_opencode_agent_session_binding.py | 36 ++++++++++++++++ tests/test_streaming_output_contract.py | 14 ++++++- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/opencode_a2a_serve/agent.py b/src/opencode_a2a_serve/agent.py index e500418..0ae6852 100644 --- a/src/opencode_a2a_serve/agent.py +++ b/src/opencode_a2a_serve/agent.py @@ -69,6 +69,8 @@ class _StreamPartState: @dataclass class _StreamOutputState: user_text: str + stable_message_id: str + event_id_namespace: str content_buffers: dict[BlockType, str] = field(default_factory=dict) saw_any_chunk: bool = False emitted_stream_chunk: bool = False @@ -121,6 +123,16 @@ def next_sequence(self) -> int: self.sequence += 1 return self.sequence + def resolve_message_id(self, message_id: str | None) -> str: + if isinstance(message_id, str): + normalized = message_id.strip() + if normalized: + return normalized + return self.stable_message_id + + def build_event_id(self, sequence: int) -> str: + return f"{self.event_id_namespace}:{sequence}" + class _TTLCache: """Bounded TTL cache for hashable key -> string value. @@ -328,7 +340,11 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non ) stream_artifact_id = f"{task_id}:stream" - stream_state = _StreamOutputState(user_text=user_text) + stream_state = _StreamOutputState( + user_text=user_text, + stable_message_id=f"{task_id}:{context_id}:assistant", + event_id_namespace=f"{task_id}:{context_id}:{stream_artifact_id}", + ) stop_event = asyncio.Event() stream_task: asyncio.Task[None] | None = None pending_preferred_claim = False @@ -393,15 +409,17 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non pending_preferred_claim = False response_text = response.text or "" + resolved_message_id = stream_state.resolve_message_id(response.message_id) logger.debug( "OpenCode response task_id=%s session_id=%s message_id=%s text=%s", task_id, response.session_id, - response.message_id, + resolved_message_id, response_text, ) if streaming_request: if stream_state.should_emit_final_snapshot(response_text): + sequence = stream_state.next_sequence() await _enqueue_artifact_update( event_queue=event_queue, task_id=task_id, @@ -414,8 +432,9 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non block_type=BlockType.TEXT, source="final_snapshot", event_type="message.finalized", - message_id=response.message_id, - sequence=stream_state.next_sequence(), + message_id=resolved_message_id, + sequence=sequence, + event_id=stream_state.build_event_id(sequence), ), ) await event_queue.enqueue_event( @@ -429,7 +448,8 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non metadata={ "opencode": { "session_id": response.session_id, - "message_id": response.message_id, + "message_id": resolved_message_id, + "event_id": f"{stream_state.event_id_namespace}:status", } }, ) @@ -440,7 +460,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non task_id=task_id, context_id=context_id, text=response_text, - message_id=response.message_id, + message_id=resolved_message_id, ) artifact = Artifact( artifact_id=str(uuid.uuid4()), @@ -457,7 +477,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non metadata={ "opencode": { "session_id": response.session_id, - "message_id": response.message_id, + "message_id": resolved_message_id, } }, ) @@ -733,6 +753,7 @@ async def _emit_chunks(chunks: list[_NormalizedStreamChunk]) -> None: for chunk in chunks: if not stream_state.matches_expected_message(chunk.message_id): continue + resolved_message_id = stream_state.resolve_message_id(chunk.message_id) if stream_state.should_drop_initial_user_echo( chunk.text, block_type=chunk.block_type, @@ -746,6 +767,7 @@ async def _emit_chunks(chunks: list[_NormalizedStreamChunk]) -> None: ) if not should_emit: continue + sequence = stream_state.next_sequence() await _enqueue_artifact_update( event_queue=event_queue, task_id=task_id, @@ -758,9 +780,10 @@ async def _emit_chunks(chunks: list[_NormalizedStreamChunk]) -> None: block_type=chunk.block_type, source=chunk.source, event_type=chunk.event_type, - message_id=chunk.message_id, + message_id=resolved_message_id, role=chunk.role, - sequence=stream_state.next_sequence(), + sequence=sequence, + event_id=stream_state.build_event_id(sequence), ), ) logger.debug( @@ -1097,6 +1120,7 @@ def _build_stream_artifact_metadata( message_id: str | None = None, role: str | None = None, sequence: int | None = None, + event_id: str | None = None, ) -> dict[str, Any]: opencode_meta: dict[str, Any] = { "block_type": block_type, @@ -1109,6 +1133,8 @@ def _build_stream_artifact_metadata( opencode_meta["role"] = role if sequence is not None: opencode_meta["sequence"] = sequence + if event_id: + opencode_meta["event_id"] = event_id return {"opencode": opencode_meta} diff --git a/tests/test_opencode_agent_session_binding.py b/tests/test_opencode_agent_session_binding.py index d36783c..0c9ce21 100644 --- a/tests/test_opencode_agent_session_binding.py +++ b/tests/test_opencode_agent_session_binding.py @@ -1,8 +1,10 @@ import asyncio import pytest +from a2a.types import Task from opencode_a2a_serve.agent import OpencodeAgentExecutor +from opencode_a2a_serve.opencode_client import OpencodeMessage from tests.helpers import DummyChatOpencodeClient, DummyEventQueue, make_request_context @@ -83,3 +85,37 @@ async def run_one(task_id: str) -> None: await asyncio.gather(run_one("t-1"), run_one("t-2"), run_one("t-3")) assert client.created_sessions == 1 + + +@pytest.mark.asyncio +async def test_agent_uses_stable_fallback_message_id_when_upstream_missing_message_id() -> None: + class MissingMessageIdClient(DummyChatOpencodeClient): + async def send_message( + self, + session_id: str, + text: str, + *, + directory: str | None = None, + timeout_override=None, # noqa: ANN001 + ) -> OpencodeMessage: + del text, directory, timeout_override + self.sent_session_ids.append(session_id) + return OpencodeMessage( + text="echo:hello", + session_id=session_id, + message_id=None, + raw={}, + ) + + client = MissingMessageIdClient() + executor = OpencodeAgentExecutor(client, streaming_enabled=False) + q = DummyEventQueue() + + await executor.execute( + make_request_context(task_id="t-fallback", context_id="c-fallback", text="hello"), + q, + ) + + task = next(event for event in q.events if isinstance(event, Task)) + assert task.metadata["opencode"]["message_id"] == "t-fallback:c-fallback:assistant" + assert task.status.message.message_id == "t-fallback:c-fallback:assistant" diff --git a/tests/test_streaming_output_contract.py b/tests/test_streaming_output_contract.py index 17e5371..082aa8e 100644 --- a/tests/test_streaming_output_contract.py +++ b/tests/test_streaming_output_contract.py @@ -1,7 +1,7 @@ import asyncio import pytest -from a2a.types import TaskArtifactUpdateEvent +from a2a.types import TaskArtifactUpdateEvent, TaskStatusUpdateEvent from opencode_a2a_serve.agent import OpencodeAgentExecutor from opencode_a2a_serve.opencode_client import OpencodeMessage @@ -14,7 +14,7 @@ def __init__( *, stream_events_payload: list[dict], response_text: str, - response_message_id: str = "msg-1", + response_message_id: str | None = "msg-1", send_delay: float = 0.02, ) -> None: self._stream_events_payload = stream_events_payload @@ -167,6 +167,8 @@ async def test_streaming_filters_user_echo_and_emits_single_artifact_block_types assert len(set(artifact_ids)) == 1 sequences = [event.artifact.metadata["opencode"]["sequence"] for event in updates] assert sequences == list(range(1, len(updates) + 1)) + event_ids = [event.artifact.metadata["opencode"]["event_id"] for event in updates] + assert event_ids == [f"task-1:ctx-1:task-1:stream:{seq}" for seq in sequences] @pytest.mark.asyncio @@ -272,6 +274,7 @@ async def test_streaming_drops_events_without_message_id_and_falls_back_to_snaps ), ], response_text="final answer from send_message", + response_message_id=None, ) executor = OpencodeAgentExecutor(client, streaming_enabled=True) executor._should_stream = lambda context: True # type: ignore[method-assign] @@ -287,6 +290,13 @@ async def test_streaming_drops_events_without_message_id_and_falls_back_to_snaps assert _part_text(update) == "final answer from send_message" assert update.artifact.metadata["opencode"]["source"] == "final_snapshot" assert update.artifact.metadata["opencode"]["block_type"] == "text" + assert update.artifact.metadata["opencode"]["message_id"] == "task-6:ctx-6:assistant" + assert update.artifact.metadata["opencode"]["event_id"] == "task-6:ctx-6:task-6:stream:1" + final_status = [ + event for event in queue.events if isinstance(event, TaskStatusUpdateEvent) and event.final + ][-1] + assert final_status.metadata["opencode"]["message_id"] == "task-6:ctx-6:assistant" + assert final_status.metadata["opencode"]["event_id"] == "task-6:ctx-6:task-6:stream:status" def _unique(items: list[str]) -> list[str]: From ddb3c5b3897ad1aa8c26e7d884d046a207a90a60 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Sun, 15 Feb 2026 07:33:31 -0500 Subject: [PATCH 8/8] feat: default-disable OpenCode LSP in deploy and streamline stream metadata (#89 #90) --- docs/deployment.md | 13 ++++++++++++- scripts/deploy.sh | 10 ++++++++-- scripts/deploy/run_opencode.sh | 20 ++++++++++++++++++++ scripts/deploy/setup_instance.sh | 1 + src/opencode_a2a_serve/agent.py | 21 ++------------------- 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index f5bd718..974e3d8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -79,7 +79,7 @@ GH_TOKEN='' A2A_BEARER_TOKEN='' \ ./scripts/deploy.sh project=alpha a2a_port=8010 a2a_host=127.0.0.1 a2a_public_url=https://a2a.example.com ``` -Supported CLI keys (case-insensitive): `project`/`project_name`, `data_root`, `a2a_port`, `a2a_host`, `a2a_public_url`, `a2a_streaming`, `a2a_log_level`, `a2a_log_payloads`, `a2a_log_body_limit`, `opencode_provider_id`, `opencode_model_id`, `repo_url`, `repo_branch`, `opencode_timeout`, `opencode_timeout_stream`, `git_identity_name`, `git_identity_email`, `update_a2a`, `force_restart`. +Supported CLI keys (case-insensitive): `project`/`project_name`, `data_root`, `a2a_port`, `a2a_host`, `a2a_public_url`, `a2a_streaming`, `a2a_log_level`, `a2a_log_payloads`, `a2a_log_body_limit`, `opencode_provider_id`, `opencode_model_id`, `opencode_lsp`, `repo_url`, `repo_branch`, `opencode_timeout`, `opencode_timeout_stream`, `git_identity_name`, `git_identity_email`, `update_a2a`, `force_restart`. Required secret env vars: `GH_TOKEN`, `A2A_BEARER_TOKEN` @@ -106,6 +106,16 @@ GH_TOKEN='' A2A_BEARER_TOKEN='' \ ./scripts/deploy.sh project=alpha a2a_port=8010 ``` +LSP behavior: + +- Deployment default is `OPENCODE_LSP=false` (LSP disabled). +- To enable LSP for one instance, pass `opencode_lsp=true`: + +```bash +GH_TOKEN='' A2A_BEARER_TOKEN='' \ +./scripts/deploy.sh project=alpha opencode_lsp=true +``` + Upgrade an existing instance after shared-code update: ```bash @@ -220,6 +230,7 @@ Naming rule in the tables below: | `OPENCODE_EXTRA_ARGS` | - | Optional | empty | Extra OpenCode startup args. | | `OPENCODE_PROVIDER_ID` | `opencode_provider_id` | Optional | None | Written to `a2a.env`. | | `OPENCODE_MODEL_ID` | `opencode_model_id` | Optional | None | Written to `a2a.env`. | +| `OPENCODE_LSP` | `opencode_lsp` | Optional | `false` | Global OpenCode LSP switch for deployed instance. Wrapper injects default `OPENCODE_CONFIG_CONTENT` with this value when `OPENCODE_CONFIG_CONTENT` is unset. | | `OPENCODE_TIMEOUT` | `opencode_timeout` | Optional | `300` | OpenCode request timeout (seconds). | | `OPENCODE_TIMEOUT_STREAM` | `opencode_timeout_stream` | Optional | None | OpenCode streaming timeout (seconds). | | `GIT_IDENTITY_NAME` | `git_identity_name` | Optional | `OpenCode-` | Git author/committer name. | diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 999d85b..9eb9bd1 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Deploy an isolated OpenCode + A2A instance (systemd services). -# Usage: ./deploy.sh project= [data_root=] [a2a_port=] [a2a_host=] [a2a_public_url=] [a2a_streaming=] [a2a_log_level=] [a2a_log_payloads=] [a2a_log_body_limit=] [opencode_provider_id=] [opencode_model_id=] [repo_url=] [repo_branch=] [opencode_timeout=] [opencode_timeout_stream=] [git_identity_name=] [git_identity_email=] [update_a2a=true] [force_restart=true] +# Usage: ./deploy.sh project= [data_root=] [a2a_port=] [a2a_host=] [a2a_public_url=] [a2a_streaming=] [a2a_log_level=] [a2a_log_payloads=] [a2a_log_body_limit=] [opencode_provider_id=] [opencode_model_id=] [opencode_lsp=] [repo_url=] [repo_branch=] [opencode_timeout=] [opencode_timeout_stream=] [git_identity_name=] [git_identity_email=] [update_a2a=true] [force_restart=true] # Required env: GH_TOKEN, A2A_BEARER_TOKEN # Optional provider secret env: see scripts/deploy/provider_secret_env_keys.sh # Requires: sudo access to write systemd units and create users/directories. @@ -29,6 +29,7 @@ A2A_LOG_BODY_LIMIT_INPUT="" DATA_ROOT_INPUT="" OPENCODE_PROVIDER_ID_INPUT="" OPENCODE_MODEL_ID_INPUT="" +OPENCODE_LSP_INPUT="" REPO_URL_INPUT="" REPO_BRANCH_INPUT="" OPENCODE_TIMEOUT_INPUT="" @@ -89,6 +90,9 @@ for arg in "$@"; do opencode_model_id) OPENCODE_MODEL_ID_INPUT="$value" ;; + opencode_lsp) + OPENCODE_LSP_INPUT="$value" + ;; repo_url) REPO_URL_INPUT="$value" ;; @@ -130,7 +134,7 @@ Usage: GH_TOKEN= A2A_BEARER_TOKEN= [=] \ ./scripts/deploy.sh project= [data_root=] [a2a_port=] [a2a_host=] [a2a_public_url=] \ [a2a_streaming=] [a2a_log_level=] [a2a_log_payloads=] [a2a_log_body_limit=] \ - [opencode_provider_id=] [opencode_model_id=] [repo_url=] [repo_branch=] \ + [opencode_provider_id=] [opencode_model_id=] [opencode_lsp=] [repo_url=] [repo_branch=] \ [opencode_timeout=] [opencode_timeout_stream=] [git_identity_name=] \ [git_identity_email=] [update_a2a=true] [force_restart=true] @@ -155,6 +159,7 @@ export_if_present() { export_if_present "OPENCODE_PROVIDER_ID" "$OPENCODE_PROVIDER_ID_INPUT" export_if_present "OPENCODE_MODEL_ID" "$OPENCODE_MODEL_ID_INPUT" +export_if_present "OPENCODE_LSP" "$OPENCODE_LSP_INPUT" export_if_present "REPO_URL" "$REPO_URL_INPUT" export_if_present "REPO_BRANCH" "$REPO_BRANCH_INPUT" export_if_present "OPENCODE_TIMEOUT" "$OPENCODE_TIMEOUT_INPUT" @@ -166,6 +171,7 @@ export_if_present "DATA_ROOT" "$DATA_ROOT_INPUT" export OPENCODE_BIND_HOST="${OPENCODE_BIND_HOST:-127.0.0.1}" export OPENCODE_LOG_LEVEL="${OPENCODE_LOG_LEVEL:-DEBUG}" export OPENCODE_EXTRA_ARGS="${OPENCODE_EXTRA_ARGS:-}" +export OPENCODE_LSP="${OPENCODE_LSP:-false}" if [[ -n "$A2A_HOST_INPUT" ]]; then export A2A_HOST="$A2A_HOST_INPUT" diff --git a/scripts/deploy/run_opencode.sh b/scripts/deploy/run_opencode.sh index 8461e52..065698e 100755 --- a/scripts/deploy/run_opencode.sh +++ b/scripts/deploy/run_opencode.sh @@ -10,6 +10,7 @@ OPENCODE_BIND_PORT="${OPENCODE_BIND_PORT:-4096}" OPENCODE_EXTRA_ARGS="${OPENCODE_EXTRA_ARGS:-}" OPENCODE_PROVIDER_ID="${OPENCODE_PROVIDER_ID:-}" OPENCODE_MODEL_ID="${OPENCODE_MODEL_ID:-}" +OPENCODE_LSP="${OPENCODE_LSP:-false}" GOOGLE_GENERATIVE_AI_API_KEY="${GOOGLE_GENERATIVE_AI_API_KEY:-}" if [[ ! -x "$OPENCODE_BIN" ]]; then @@ -26,6 +27,25 @@ if [[ "$provider_lc" == "google" || "$model_lc" == *"gemini"* ]]; then fi fi +if [[ -z "${OPENCODE_CONFIG_CONTENT:-}" ]]; then + case "${OPENCODE_LSP,,}" in + 1|true|yes|on) + lsp_json=true + ;; + 0|false|no|off|"") + lsp_json=false + ;; + *) + echo "Invalid OPENCODE_LSP value: ${OPENCODE_LSP} (expected true/false)" >&2 + exit 1 + ;; + esac + printf -v OPENCODE_CONFIG_CONTENT \ + '{"$schema":"https://opencode.ai/config.json","lsp":%s}' \ + "$lsp_json" + export OPENCODE_CONFIG_CONTENT +fi + cmd=("$OPENCODE_BIN" serve --log-level "$OPENCODE_LOG_LEVEL" --print-logs) if [[ -n "$OPENCODE_BIND_HOST" ]]; then diff --git a/scripts/deploy/setup_instance.sh b/scripts/deploy/setup_instance.sh index 769af84..5c514b9 100755 --- a/scripts/deploy/setup_instance.sh +++ b/scripts/deploy/setup_instance.sh @@ -147,6 +147,7 @@ opencode_env_tmp="$(mktemp)" echo "OPENCODE_BIND_HOST=${OPENCODE_BIND_HOST}" echo "OPENCODE_BIND_PORT=${OPENCODE_BIND_PORT}" echo "OPENCODE_EXTRA_ARGS=${OPENCODE_EXTRA_ARGS:-}" + echo "OPENCODE_LSP=${OPENCODE_LSP:-false}" echo "GH_TOKEN=${GH_TOKEN}" echo "GIT_ASKPASS=${ASKPASS_SCRIPT}" echo "GIT_ASKPASS_REQUIRE=force" diff --git a/src/opencode_a2a_serve/agent.py b/src/opencode_a2a_serve/agent.py index 0ae6852..bd512e1 100644 --- a/src/opencode_a2a_serve/agent.py +++ b/src/opencode_a2a_serve/agent.py @@ -45,7 +45,6 @@ class _NormalizedStreamChunk: append: bool block_type: BlockType source: str - event_type: str message_id: str | None role: str | None @@ -431,7 +430,6 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non artifact_metadata=_build_stream_artifact_metadata( block_type=BlockType.TEXT, source="final_snapshot", - event_type="message.finalized", message_id=resolved_message_id, sequence=sequence, event_id=stream_state.build_event_id(sequence), @@ -779,7 +777,6 @@ async def _emit_chunks(chunks: list[_NormalizedStreamChunk]) -> None: artifact_metadata=_build_stream_artifact_metadata( block_type=chunk.block_type, source=chunk.source, - event_type=chunk.event_type, message_id=resolved_message_id, role=chunk.role, sequence=sequence, @@ -801,7 +798,6 @@ def _new_chunk( append: bool, block_type: BlockType, source: str, - event_type: str, message_id: str | None, role: str | None, ) -> _NormalizedStreamChunk: @@ -810,7 +806,6 @@ def _new_chunk( append=append, block_type=block_type, source=source, - event_type=event_type, message_id=message_id, role=role, ) @@ -847,7 +842,6 @@ def _delta_chunks( state: _StreamPartState, delta_text: str, message_id: str | None, - event_type: str, source: str, ) -> list[_NormalizedStreamChunk]: if not delta_text: @@ -862,7 +856,6 @@ def _delta_chunks( append=True, block_type=state.block_type, source=source, - event_type=event_type, message_id=state.message_id, role=state.role, ) @@ -873,7 +866,6 @@ def _snapshot_chunks( state: _StreamPartState, snapshot: str, message_id: str | None, - event_type: str, part_id: str, ) -> list[_NormalizedStreamChunk]: if message_id: @@ -892,7 +884,6 @@ def _snapshot_chunks( append=True, block_type=state.block_type, source="part_text_diff", - event_type=event_type, message_id=state.message_id, role=state.role, ) @@ -914,7 +905,6 @@ def _tool_chunks( state: _StreamPartState, part: Mapping[str, Any], message_id: str | None, - event_type: str, ) -> list[_NormalizedStreamChunk]: tool_chunk = _serialize_tool_part(part) if not tool_chunk: @@ -932,7 +922,6 @@ def _tool_chunks( append=bool(previous), block_type=state.block_type, source="tool_part_update", - event_type=event_type, message_id=state.message_id, role=state.role, ) @@ -985,7 +974,6 @@ def _tool_chunks( state=state, delta_text=delta, message_id=message_id, - event_type=event_type, source="delta_event", ) if chunks: @@ -1017,7 +1005,6 @@ def _tool_chunks( state=state, delta_text=buffered.delta, message_id=buffered.message_id, - event_type="message.part.delta", source="delta_event_buffered", ) ) @@ -1029,7 +1016,6 @@ def _tool_chunks( state=state, delta_text=delta, message_id=message_id, - event_type=event_type, source="delta", ) ) @@ -1039,7 +1025,6 @@ def _tool_chunks( state=state, part=part, message_id=message_id, - event_type=event_type, ) ) elif isinstance(part.get("text"), str): @@ -1048,7 +1033,6 @@ def _tool_chunks( state=state, snapshot=part["text"], message_id=message_id, - event_type=event_type, part_id=part_id, ) ) @@ -1095,6 +1079,7 @@ async def _enqueue_artifact_update( artifact_metadata: Mapping[str, Any] | None = None, event_metadata: Mapping[str, Any] | None = None, ) -> None: + normalized_last_chunk = True if last_chunk is True else None artifact = Artifact( artifact_id=artifact_id, parts=[TextPart(text=text)], @@ -1106,7 +1091,7 @@ async def _enqueue_artifact_update( context_id=context_id, artifact=artifact, append=append, - last_chunk=last_chunk, + last_chunk=normalized_last_chunk, metadata=dict(event_metadata) if event_metadata else None, ) ) @@ -1116,7 +1101,6 @@ def _build_stream_artifact_metadata( *, block_type: BlockType, source: str, - event_type: str, message_id: str | None = None, role: str | None = None, sequence: int | None = None, @@ -1125,7 +1109,6 @@ def _build_stream_artifact_metadata( opencode_meta: dict[str, Any] = { "block_type": block_type, "source": source, - "event_type": event_type, } if message_id: opencode_meta["message_id"] = message_id