From 81887077acf838fc8b38421ab4517985ccf70723 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 11:26:04 +0100 Subject: [PATCH 01/30] refactor(upstream): add typed adapter boundary (#2) --- docs/maintainer-architecture.md | 11 +- src/opencode_a2a/execution/coordinator.py | 94 +++++-- src/opencode_a2a/execution/stream_events.py | 107 ++++++++ src/opencode_a2a/execution/stream_runtime.py | 246 +++++++++---------- src/opencode_a2a/opencode_upstream_client.py | 80 ++++++ src/opencode_a2a/upstream/__init__.py | 25 ++ src/opencode_a2a/upstream/contracts.py | 118 +++++++++ tests/support/streaming_output.py | 61 +++++ tests/upstream/test_upstream_contracts.py | 126 ++++++++++ 9 files changed, 717 insertions(+), 151 deletions(-) create mode 100644 src/opencode_a2a/upstream/__init__.py create mode 100644 src/opencode_a2a/upstream/contracts.py create mode 100644 tests/upstream/test_upstream_contracts.py diff --git a/docs/maintainer-architecture.md b/docs/maintainer-architecture.md index b30054cd..8ad64da5 100644 --- a/docs/maintainer-architecture.md +++ b/docs/maintainer-architecture.md @@ -20,7 +20,8 @@ flowchart TD end subgraph Upstream["OpenCode Upstream Layer"] - UpstreamClient["opencode_upstream_client.py"] + UpstreamClient["opencode_upstream_client.py (V1 facade)"] + UpstreamContracts["upstream/contracts.py"] end subgraph Extensions["Extension / Contract Layer"] @@ -37,7 +38,8 @@ flowchart TD Inbound --> Execution Inbound --> Extensions - Execution --> Upstream + Execution --> UpstreamContracts + UpstreamClient --> UpstreamContracts Inbound --> Persistence Execution --> Persistence ``` @@ -53,8 +55,9 @@ flowchart TD - `execution/coordinator.py` - `execution/stream_runtime.py` - `execution/tool_orchestration.py` -5. `opencode_upstream_client.py` sends requests to the upstream OpenCode runtime. -6. The adapter maps upstream responses back into A2A tasks, messages, artifacts, and stream events. +5. `opencode_upstream_client.py` preserves the public V1 client facade and sends requests to the OpenCode runtime. +6. The internal `upstream/contracts.py` separates prompt receipts, terminal outcomes, and normalized execution updates; V1 event decoding occurs before updates reach the shared stream runtime. +7. The execution layer maps outcomes and updates into A2A tasks, messages, artifacts, and stream events. ### JSON-RPC Extension Path diff --git a/src/opencode_a2a/execution/coordinator.py b/src/opencode_a2a/execution/coordinator.py index e463f96c..29ede9f2 100644 --- a/src/opencode_a2a/execution/coordinator.py +++ b/src/opencode_a2a/execution/coordinator.py @@ -23,8 +23,17 @@ ) from ..opencode_upstream_client import UpstreamConcurrencyLimitError, UpstreamContractError +from ..upstream.contracts import ( + ExecutionOutcome, + PromptSubmission, + UpstreamAdapter, +) from .event_helpers import _enqueue_artifact_update -from .stream_events import _extract_token_usage, _extract_upstream_error_from_response +from .stream_events import ( + _extract_token_usage, + _extract_upstream_error_from_response, + _task_state_from_execution_state, +) from .stream_state import ( _build_output_metadata, _build_stream_artifact_metadata, @@ -36,6 +45,7 @@ _await_stream_terminal_signal, _format_upstream_error, _StreamTerminalSignal, + _UpstreamInBandError, ) if TYPE_CHECKING: @@ -134,19 +144,31 @@ async def run(self) -> None: if self._prepared.streaming_request: send_kwargs["timeout_override"] = self._executor._client.stream_timeout - if not self._prepared.use_structured_parts and not turn_request_parts: - response = await self._executor._client.send_message( - self._session_id, - user_text, - **send_kwargs, - ) - else: - response = await self._executor._client.send_message( + prompt_text = ( + user_text + if not self._prepared.use_structured_parts and not turn_request_parts + else user_text or None + ) + prompt_kwargs = dict(send_kwargs) + if self._prepared.use_structured_parts or turn_request_parts: + prompt_kwargs["parts"] = turn_request_parts + + upstream: UpstreamAdapter = self._executor._client + submit_prompt = getattr(upstream, "submit_prompt", None) + submission = ( + await submit_prompt(self._session_id, prompt_text, **prompt_kwargs) + if callable(submit_prompt) + else None + ) + response = ( + submission.outcome + if isinstance(submission, PromptSubmission) + else await self._executor._client.send_message( self._session_id, - user_text or None, - parts=turn_request_parts, - **send_kwargs, + prompt_text, + **prompt_kwargs, ) + ) if self._pending_preferred_claim: await self._executor._session_manager.finalize_preferred_session_binding( @@ -156,8 +178,13 @@ async def run(self) -> None: ) self._pending_preferred_claim = False + response_payload = ( + dict(response.response or {}) + if isinstance(response, ExecutionOutcome) + else response.raw + ) tool_results = await maybe_handle_tools( - response.raw, + response_payload, a2a_client_manager=self._executor._a2a_client_manager, ) if tool_results: @@ -300,18 +327,42 @@ async def _bind_session(self) -> None: ) async def _handle_response(self, response: Any) -> None: - response_text = response.text or "" + response_text = ( + response.output if isinstance(response, ExecutionOutcome) else response.text + ) or "" resolved_message_id = self._stream_state.resolve_message_id(response.message_id) - response_error = _extract_upstream_error_from_response(response.raw) + response_session_id = ( + response.session.id if isinstance(response, ExecutionOutcome) else response.session_id + ) + response_payload = ( + dict(response.response or {}) + if isinstance(response, ExecutionOutcome) + else response.raw + ) + response_error = ( + _UpstreamInBandError( + error_type=response.error_type, + state=_task_state_from_execution_state(response.error_state), + message=response.error_message, + upstream_status=response.upstream_status, + ) + if isinstance(response, ExecutionOutcome) + and response.error_type is not None + and response.error_state is not None + and response.error_message is not None + else _extract_upstream_error_from_response(response_payload) + ) resolved_token_usage = _merge_token_usage( - _extract_token_usage(response.raw), + response.usage + if isinstance(response, ExecutionOutcome) + else _extract_token_usage(response_payload), self._stream_state.token_usage, ) logger.debug( "OpenCode response task_id=%s session_id=%s message_id=%s text_len=%s", self._task_id, - response.session_id, + response_session_id, resolved_message_id, len(response_text), ) @@ -331,7 +382,6 @@ async def _handle_response(self, response: Any) -> None: if self._prepared.streaming_request: await self._handle_streaming_response( - response=response, response_text=response_text, resolved_message_id=resolved_message_id, resolved_token_usage=resolved_token_usage, @@ -339,7 +389,7 @@ async def _handle_response(self, response: Any) -> None: return await self._handle_non_streaming_response( - response=response, + session_id=response_session_id, response_text=response_text, resolved_message_id=resolved_message_id, resolved_token_usage=resolved_token_usage, @@ -348,14 +398,12 @@ async def _handle_response(self, response: Any) -> None: async def _handle_streaming_response( self, *, - response: Any, response_text: str, resolved_message_id: str, resolved_token_usage: Mapping[str, Any] | None, ) -> None: from .stream_events import BlockType - del response if self._stream_terminal_signal is None: raise RuntimeError("Streaming terminal signal was not initialized") @@ -439,7 +487,7 @@ async def _handle_streaming_response( async def _handle_non_streaming_response( self, *, - response: Any, + session_id: str, response_text: str, resolved_message_id: str, resolved_token_usage: Mapping[str, Any] | None, @@ -470,7 +518,7 @@ async def _handle_non_streaming_response( history=history, artifacts=[artifact], metadata=_build_output_metadata( - session_id=response.session_id, + session_id=session_id, usage=resolved_token_usage, include_session_metadata=self._prepared.emit_session_metadata, include_streaming_metadata=self._prepared.emit_streaming_metadata, diff --git a/src/opencode_a2a/execution/stream_events.py b/src/opencode_a2a/execution/stream_events.py index 4d5458b5..40239afc 100644 --- a/src/opencode_a2a/execution/stream_events.py +++ b/src/opencode_a2a/execution/stream_events.py @@ -7,6 +7,14 @@ from a2a.types import TaskState +from ..upstream.contracts import ( + ExecutionOutcome, + ExecutionState, + ExecutionUpdate, + Intervention, + SessionRef, + UpdateKind, +) from .stream_state import BlockType from .upstream_error_translator import ( _format_inband_upstream_error, @@ -541,3 +549,102 @@ def _extract_tool_part_payload(part: Mapping[str, Any]) -> dict[str, Any] | None if not payload: return None return payload + + +def normalize_v1_event(event: Mapping[str, Any], *, log_limit: int = 0) -> ExecutionUpdate | None: + """Decode a V1 event into the protocol-neutral execution update contract.""" + _log_stream_event_debug(event, limit=log_limit) + event_type = event.get("type") + props = event.get("properties") + if not isinstance(event_type, str) or not isinstance(props, Mapping): + return None + + part_value = props.get("part") + part = part_value if isinstance(part_value, Mapping) else {} + session_id = _extract_event_session_id(event) + message_id = _extract_stream_message_id(part, props) + part_id = _extract_stream_part_id(part, props) + part_type = _extract_stream_part_type(part, props) + role = _normalize_role(part.get("role") or props.get("role")) + delta_value = props.get("delta") + delta = delta_value if isinstance(delta_value, str) else None + field_value = props.get("field") + delta_field = field_value if isinstance(field_value, str) else None + + kind = UpdateKind.OTHER + if event_type == "message.part.updated": + kind = UpdateKind.PART_UPDATED + elif event_type == "message.part.delta": + kind = UpdateKind.PART_DELTA + + error = _extract_upstream_error_from_event(event) + signal = _extract_stream_terminal_signal(event) + terminal = None + if signal is not None: + terminal = ExecutionOutcome( + session=SessionRef(session_id or ""), + state=_execution_state(signal.state), + output="", + error_type=signal.error_type, + error_message=signal.message, + error_state=_execution_state(signal.state), + upstream_status=signal.upstream_status, + ) + + asked = _extract_interrupt_asked_event(event) + intervention = ( + Intervention( + request_id=asked["request_id"], + type=asked["interrupt_type"], + details=asked["details"], + ) + if asked is not None + else None + ) + resolved = _extract_interrupt_resolved_event(event) + progress = _extract_progress_metadata(part, props) if isinstance(part_value, Mapping) else None + + return ExecutionUpdate( + kind=kind, + session_id=session_id, + message_id=message_id, + part_id=part_id, + part_type=part_type, + role=role, + delta_field=delta_field, + delta=delta, + snapshot=_extract_stream_snapshot_text(part), + tool_payload=_extract_tool_part_payload(part), + progress=progress, + progress_identity=(_build_progress_identity(part, props) if progress is not None else None), + usage=_extract_token_usage(event), + error_type=error.error_type if error is not None else None, + error_message=error.message if error is not None else None, + error_state=_execution_state(error.state) if error is not None else None, + upstream_status=error.upstream_status if error is not None else None, + terminal=terminal, + intervention=intervention, + resolved_request_id=resolved["request_id"] if resolved is not None else None, + resolved_intervention_type=(resolved["interrupt_type"] if resolved is not None else None), + resolution=resolved["resolution"] if resolved is not None else None, + ) + + +def _execution_state(state: TaskState) -> ExecutionState: + if state == TaskState.TASK_STATE_COMPLETED: + return ExecutionState.COMPLETED + if state == TaskState.TASK_STATE_CANCELED: + return ExecutionState.CANCELLED + if state == TaskState.TASK_STATE_AUTH_REQUIRED: + return ExecutionState.AUTH_REQUIRED + return ExecutionState.FAILED + + +def _task_state_from_execution_state(state: ExecutionState) -> TaskState: + if state == ExecutionState.COMPLETED: + return TaskState.TASK_STATE_COMPLETED + if state == ExecutionState.CANCELLED: + return TaskState.TASK_STATE_CANCELED + if state == ExecutionState.AUTH_REQUIRED: + return TaskState.TASK_STATE_AUTH_REQUIRED + return TaskState.TASK_STATE_FAILED diff --git a/src/opencode_a2a/execution/stream_runtime.py b/src/opencode_a2a/execution/stream_runtime.py index 54e76055..cf811998 100644 --- a/src/opencode_a2a/execution/stream_runtime.py +++ b/src/opencode_a2a/execution/stream_runtime.py @@ -1,10 +1,11 @@ from __future__ import annotations import asyncio +import inspect import json import logging from collections import defaultdict -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from typing import Any from a2a.server.events.event_queue import EventQueue @@ -17,26 +18,12 @@ from ..a2a_utils import make_data_part from ..interrupt_request_tracker import BoundInterruptRequestTracker +from ..upstream.contracts import ExecutionUpdate, UpdateKind from .event_helpers import _enqueue_artifact_update from .stream_events import ( BlockType, - _build_progress_identity, - _extract_event_session_id, - _extract_interrupt_asked_event, - _extract_interrupt_resolved_event, - _extract_progress_metadata, - _extract_stream_message_id, - _extract_stream_part_id, - _extract_stream_part_type, - _extract_stream_session_id, - _extract_stream_snapshot_text, - _extract_stream_terminal_signal, - _extract_token_usage, - _extract_tool_part_payload, - _extract_upstream_error_from_event, - _log_stream_event_debug, _map_part_type_to_block_type, - _normalize_role, + _task_state_from_execution_state, ) from .stream_state import ( _build_output_metadata, @@ -46,11 +33,33 @@ _StreamOutputState, _StreamPartState, ) -from .upstream_error_translator import _StreamTerminalSignal +from .upstream_error_translator import ( + _StreamTerminalSignal, + _UpstreamInBandError, +) logger = logging.getLogger("opencode_a2a.execution.executor") +async def _client_stream_updates(client: Any, **kwargs: Any) -> AsyncIterator[ExecutionUpdate]: + stream_updates = getattr(client, "stream_updates", None) + if callable(stream_updates) and not inspect.iscoroutinefunction(stream_updates): + updates = stream_updates(**kwargs) + if hasattr(updates, "__aiter__"): + async for update in updates: + yield update + return + + from .stream_events import normalize_v1_event + + settings = getattr(client, "settings", None) + log_limit = getattr(settings, "a2a_log_body_limit", 0) + async for event in client.stream_events(**kwargs): + update = normalize_v1_event(event, log_limit=log_limit) + if update is not None: + yield update + + class StreamRuntime: def __init__( self, @@ -262,12 +271,11 @@ def _resolve_stream_artifact_id( def _upsert_part_state( *, part_id: str, - part: Mapping[str, Any], - props: Mapping[str, Any], + part_type: str | None, role: str | None, message_id: str | None, ) -> _StreamPartState | None: - block_type = _map_part_type_to_block_type(_extract_stream_part_type(part, props)) + block_type = _map_part_type_to_block_type(part_type) if block_type is None: return None state = part_states.get(part_id) @@ -368,10 +376,9 @@ def _snapshot_chunks( def _tool_chunks( *, state: _StreamPartState, - part: Mapping[str, Any], + tool_payload: Mapping[str, Any] | None, message_id: str | None, ) -> list[_NormalizedStreamChunk]: - tool_payload = _extract_tool_part_payload(part) if tool_payload is None: return [] content_key = json.dumps( @@ -409,108 +416,102 @@ def _tool_chunks( stream_kwargs["directory"] = directory if workspace_id is not None: stream_kwargs["workspace_id"] = workspace_id - async for event in self._client.stream_events(**stream_kwargs): + async for update in _client_stream_updates( + self._client, + **stream_kwargs, + ): if stop_event.is_set(): break - _log_stream_event_debug( - event, - limit=max(0, self._client.settings.a2a_log_body_limit), - ) - event_type = event.get("type") - if not isinstance(event_type, str): - continue - props = event.get("properties") - if not isinstance(props, Mapping): - continue - event_session_id = _extract_event_session_id(event) - if event_session_id == session_id: - part = props.get("part") - if isinstance(part, Mapping): - progress = _extract_progress_metadata(part, props) - if progress is not None: - progress_identity = _build_progress_identity(part, props) - progress_key = json.dumps( - progress, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), + if update.session_id == session_id: + if update.progress is not None: + progress = update.progress + progress_identity = update.progress_identity or "unknown" + progress_key = json.dumps( + progress, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + if ( + stream_state.register_progress( + identity=progress_identity, + content_key=progress_key, ) - if ( - stream_state.register_progress( - identity=progress_identity, - content_key=progress_key, - ) - and emit_streaming_metadata - ): - sequence = stream_state.next_sequence() - await event_queue.enqueue_event( - TaskStatusUpdateEvent( - task_id=task_id, - context_id=context_id, - status=TaskStatus( - state=TaskState.TASK_STATE_WORKING - ), - metadata=_build_output_metadata( - session_id=session_id, - stream={ - "message_id": ( - stream_state.resolve_message_id( - _extract_stream_message_id( - part, props - ) - ) - ), - "event_id": stream_state.build_event_id( - sequence - ), - "sequence": sequence, - }, - progress=dict(progress), - include_session_metadata=( - emit_session_metadata + and emit_streaming_metadata + ): + sequence = stream_state.next_sequence() + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=task_id, + context_id=context_id, + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), + metadata=_build_output_metadata( + session_id=session_id, + stream={ + "message_id": ( + stream_state.resolve_message_id( + update.message_id + ) ), - include_streaming_metadata=( - emit_streaming_metadata + "event_id": stream_state.build_event_id( + sequence ), - include_interrupt_metadata=( - emit_interrupt_metadata - ), - ), - ) + "sequence": sequence, + }, + progress=dict(progress), + include_session_metadata=emit_session_metadata, + include_streaming_metadata=emit_streaming_metadata, + include_interrupt_metadata=emit_interrupt_metadata, + ), ) - upstream_error = _extract_upstream_error_from_event(event) - if upstream_error is not None and stream_state.upstream_error is None: - stream_state.upstream_error = upstream_error - signal = _extract_stream_terminal_signal(event) - if signal is not None and not terminal_signal.done(): - terminal_signal.set_result(signal) + ) + if ( + update.error_type is not None + and update.error_state is not None + and update.error_message is not None + and stream_state.upstream_error is None + ): + stream_state.upstream_error = _UpstreamInBandError( + error_type=update.error_type, + state=_task_state_from_execution_state(update.error_state), + message=update.error_message, + upstream_status=update.upstream_status, + ) + if update.terminal is not None and not terminal_signal.done(): + outcome = update.terminal + terminal_signal.set_result( + _StreamTerminalSignal( + state=_task_state_from_execution_state(outcome.state), + error_type=outcome.error_type, + message=outcome.error_message, + upstream_status=outcome.upstream_status, + ) + ) stop_event.set() - usage = _extract_token_usage(event) - if usage is not None: - stream_state.ingest_token_usage(usage) - asked = _extract_interrupt_asked_event(event) - if asked is not None: - request_id = asked["request_id"] + if update.usage is not None: + stream_state.ingest_token_usage(update.usage) + if update.intervention is not None: + intervention = update.intervention + request_id = intervention.request_id if stream_state.mark_interrupt_pending(request_id): await interrupt_requests.remember_request( request_id=request_id, session_id=session_id, - interrupt_type=asked["interrupt_type"], + interrupt_type=intervention.type, identity=identity, task_id=task_id, context_id=context_id, - details=asked["details"], + details=dict(intervention.details), ) await _emit_interrupt_status( state=TaskState.TASK_STATE_INPUT_REQUIRED, request_id=request_id, - interrupt_type=asked["interrupt_type"], + interrupt_type=intervention.type, phase="asked", - details=asked["details"], + details=intervention.details, ) - resolved = _extract_interrupt_resolved_event(event) - if resolved is not None: - resolved_request_id = resolved["request_id"] + if update.resolved_request_id is not None: + resolved_request_id = update.resolved_request_id cleared_pending = stream_state.clear_interrupt_pending( resolved_request_id ) @@ -519,25 +520,24 @@ def _tool_chunks( await _emit_interrupt_status( state=TaskState.TASK_STATE_WORKING, request_id=resolved_request_id, - interrupt_type=resolved["interrupt_type"], + interrupt_type=( + update.resolved_intervention_type or "question" + ), phase="resolved", - resolution=resolved["resolution"], + resolution=update.resolution, ) - if event_type not in {"message.part.updated", "message.part.delta"}: + if update.kind not in {UpdateKind.PART_UPDATED, UpdateKind.PART_DELTA}: continue - part = props.get("part") - if not isinstance(part, Mapping): - part = {} - if _extract_stream_session_id(part, props) != session_id: + if update.session_id != session_id: continue - message_id = _extract_stream_message_id(part, props) - part_id = _extract_stream_part_id(part, props) + message_id = update.message_id + part_id = update.part_id if not part_id: continue - if event_type == "message.part.delta": - field = props.get("field") - delta = props.get("delta") + if update.kind == UpdateKind.PART_DELTA: + field = update.delta_field + delta = update.delta if field != "text" or not isinstance(delta, str) or not delta: continue state = part_states.get(part_id) @@ -562,12 +562,10 @@ def _tool_chunks( await _emit_chunks(delta_chunks) continue - role = _normalize_role(part.get("role") or props.get("role")) state = _upsert_part_state( part_id=part_id, - part=part, - props=props, - role=role, + part_type=update.part_type, + role=update.role, message_id=message_id, ) if state is None: @@ -591,7 +589,7 @@ def _tool_chunks( ) ) - delta = props.get("delta") + delta = update.delta if isinstance(delta, str) and delta: chunks.extend( _delta_chunks( @@ -605,12 +603,12 @@ def _tool_chunks( chunks.extend( _tool_chunks( state=state, - part=part, + tool_payload=update.tool_payload, message_id=message_id, ) ) else: - snapshot_text = _extract_stream_snapshot_text(part) + snapshot_text = update.snapshot if snapshot_text is not None: chunks.extend( _snapshot_chunks( diff --git a/src/opencode_a2a/opencode_upstream_client.py b/src/opencode_a2a/opencode_upstream_client.py index 74d22cbe..3007901a 100644 --- a/src/opencode_a2a/opencode_upstream_client.py +++ b/src/opencode_a2a/opencode_upstream_client.py @@ -17,6 +17,14 @@ from .runtime_state import InterruptRequestBinding from .server.state_store import InterruptRequestRepository, MemoryInterruptRequestRepository from .trace_context import current_trace_headers +from .upstream.contracts import ( + ExecutionOutcome, + ExecutionState, + ExecutionUpdate, + PromptReceipt, + PromptSubmission, + SessionRef, +) _UNSET = object() logger = logging.getLogger(__name__) @@ -451,6 +459,24 @@ async def stream_events( data_lines.append(line[5:].lstrip()) continue + async def stream_updates( + self, + stop_event: asyncio.Event | None = None, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> AsyncIterator[ExecutionUpdate]: + async for event in self.stream_events( + stop_event, + directory=directory, + workspace_id=workspace_id, + ): + from .execution.stream_events import normalize_v1_event + + update = normalize_v1_event(event, log_limit=self._settings.a2a_log_body_limit) + if update is not None: + yield update + async def create_session( self, title: str | None = None, @@ -879,6 +905,60 @@ async def send_message( raw=data, ) + async def submit_prompt( + self, + session_id: str, + text: str | None = None, + *, + parts: Sequence[Mapping[str, Any]] | None = None, + directory: str | None = None, + workspace_id: str | None = None, + model_override: Mapping[str, Any] | None = None, + timeout_override: float | None | object = _UNSET, + ) -> PromptSubmission: + """Return V1's synchronous prompt result as distinct receipt and outcome values.""" + message = await self.send_message( + session_id, + text, + parts=parts, + directory=directory, + workspace_id=workspace_id, + model_override=model_override, + timeout_override=timeout_override, + ) + from a2a.types import TaskState + + from .execution.stream_events import ( + _extract_token_usage, + _extract_upstream_error_from_response, + ) + + error = _extract_upstream_error_from_response(message.raw) + outcome_state = ExecutionState.COMPLETED + if error is not None: + outcome_state = ( + ExecutionState.AUTH_REQUIRED + if error.state == TaskState.TASK_STATE_AUTH_REQUIRED + else ExecutionState.CANCELLED + if error.state == TaskState.TASK_STATE_CANCELED + else ExecutionState.FAILED + ) + return PromptSubmission( + receipt=PromptReceipt(session=SessionRef(session_id)), + outcome=ExecutionOutcome( + session=SessionRef(session_id), + state=outcome_state, + output=message.text, + message_id=message.message_id, + usage=_extract_token_usage(message.raw), + error_type=error.error_type if error is not None else None, + error_message=error.message if error is not None else None, + error_state=outcome_state if error is not None else None, + upstream_status=error.upstream_status if error is not None else None, + response=message.raw, + ), + ) + async def permission_reply( self, request_id: str, diff --git a/src/opencode_a2a/upstream/__init__.py b/src/opencode_a2a/upstream/__init__.py new file mode 100644 index 00000000..8c7273ec --- /dev/null +++ b/src/opencode_a2a/upstream/__init__.py @@ -0,0 +1,25 @@ +"""Typed internal contracts for upstream protocol adapters.""" + +from .contracts import ( + ExecutionOutcome, + ExecutionState, + ExecutionUpdate, + Intervention, + PromptReceipt, + PromptSubmission, + SessionRef, + UpdateKind, + UpstreamAdapter, +) + +__all__ = [ + "ExecutionOutcome", + "ExecutionState", + "ExecutionUpdate", + "Intervention", + "PromptReceipt", + "PromptSubmission", + "SessionRef", + "UpdateKind", + "UpstreamAdapter", +] diff --git a/src/opencode_a2a/upstream/contracts.py b/src/opencode_a2a/upstream/contracts.py new file mode 100644 index 00000000..1579654c --- /dev/null +++ b/src/opencode_a2a/upstream/contracts.py @@ -0,0 +1,118 @@ +"""Protocol-neutral data exchanged with an upstream execution adapter.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, Protocol + + +class ExecutionState(StrEnum): + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + AUTH_REQUIRED = "auth_required" + + +class UpdateKind(StrEnum): + OTHER = "other" + PART_UPDATED = "part_updated" + PART_DELTA = "part_delta" + + +@dataclass(frozen=True) +class SessionRef: + id: str + + +@dataclass(frozen=True) +class PromptReceipt: + session: SessionRef + message_id: str | None = None + + +@dataclass(frozen=True) +class ExecutionOutcome: + session: SessionRef + state: ExecutionState + output: str + message_id: str | None = None + usage: Mapping[str, Any] | None = None + error_type: str | None = None + error_message: str | None = None + error_state: ExecutionState | None = None + upstream_status: int | None = None + response: Mapping[str, Any] | None = None + + +@dataclass(frozen=True) +class PromptSubmission: + receipt: PromptReceipt + outcome: ExecutionOutcome + + +@dataclass(frozen=True) +class Intervention: + request_id: str + type: str + details: Mapping[str, Any] + + +@dataclass(frozen=True) +class ExecutionUpdate: + kind: UpdateKind + session_id: str | None = None + message_id: str | None = None + part_id: str | None = None + part_type: str | None = None + role: str | None = None + delta_field: str | None = None + delta: str | None = None + snapshot: str | None = None + tool_payload: Mapping[str, Any] | None = None + progress: Mapping[str, Any] | None = None + progress_identity: str | None = None + usage: Mapping[str, Any] | None = None + error_type: str | None = None + error_message: str | None = None + error_state: ExecutionState | None = None + upstream_status: int | None = None + terminal: ExecutionOutcome | None = None + intervention: Intervention | None = None + resolved_request_id: str | None = None + resolved_intervention_type: str | None = None + resolution: str | None = None + + +class UpstreamAdapter(Protocol): + """Small execution surface shared by upstream protocol implementations.""" + + async def create_session( + self, + title: str | None = None, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> str: ... + + async def submit_prompt( + self, + session_id: str, + text: str | None = None, + *, + parts: Sequence[Mapping[str, Any]] | None = None, + directory: str | None = None, + workspace_id: str | None = None, + model_override: Mapping[str, Any] | None = None, + timeout_override: float | None | object = ..., + ) -> PromptSubmission: ... + + def stream_updates( + self, + stop_event: asyncio.Event | None = None, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> AsyncIterator[ExecutionUpdate]: ... diff --git a/tests/support/streaming_output.py b/tests/support/streaming_output.py index a84df732..efe4f68c 100644 --- a/tests/support/streaming_output.py +++ b/tests/support/streaming_output.py @@ -1,4 +1,5 @@ import asyncio +from collections.abc import AsyncIterator from a2a.types import ( TaskArtifactUpdateEvent, @@ -6,7 +7,16 @@ ) from google.protobuf.json_format import MessageToDict +from opencode_a2a.execution.stream_events import _extract_token_usage, normalize_v1_event from opencode_a2a.opencode_upstream_client import OpencodeMessage +from opencode_a2a.upstream.contracts import ( + ExecutionOutcome, + ExecutionState, + ExecutionUpdate, + PromptReceipt, + PromptSubmission, + SessionRef, +) from tests.support.helpers import ( DummyEventQueue, ) @@ -75,6 +85,38 @@ async def send_message( raw=self._response_raw, ) + async def submit_prompt( + self, + session_id: str, + text: str | None = None, + *, + parts: list[dict] | None = None, + directory: str | None = None, + workspace_id: str | None = None, + model_override: dict[str, str] | None = None, + timeout_override=None, # noqa: ANN001 + ) -> PromptSubmission: + del workspace_id + message = await self.send_message( + session_id, + text, + parts=parts, + directory=directory, + model_override=model_override, + timeout_override=timeout_override, + ) + return PromptSubmission( + receipt=PromptReceipt(session=SessionRef(session_id)), + outcome=ExecutionOutcome( + session=SessionRef(session_id), + state=ExecutionState.COMPLETED, + output=message.text, + message_id=message.message_id, + usage=_extract_token_usage(message.raw), + response=message.raw, + ), + ) + async def stream_events(self, stop_event=None, *, directory: str | None = None): # noqa: ANN001 del directory for index, event in enumerate(self._stream_events_payload): @@ -91,6 +133,25 @@ async def stream_events(self, stop_event=None, *, directory: str | None = None): ): yield {"type": "session.idle", "properties": {"sessionID": "ses-1"}} + async def stream_updates( + self, + stop_event: asyncio.Event | None = None, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> AsyncIterator[ExecutionUpdate]: + del workspace_id + async for event in self.stream_events( + stop_event, + directory=directory, + ): + update = normalize_v1_event( + event, + log_limit=self.settings.a2a_log_body_limit, + ) + if update is not None: + yield update + def _event( *, diff --git a/tests/upstream/test_upstream_contracts.py b/tests/upstream/test_upstream_contracts.py new file mode 100644 index 00000000..b88fd570 --- /dev/null +++ b/tests/upstream/test_upstream_contracts.py @@ -0,0 +1,126 @@ +from unittest.mock import AsyncMock + +import pytest + +from opencode_a2a.execution.stream_events import normalize_v1_event +from opencode_a2a.opencode_upstream_client import OpencodeMessage, OpencodeUpstreamClient +from opencode_a2a.upstream.contracts import ( + ExecutionState, + UpdateKind, +) +from tests.support.settings import make_settings + + +def test_v1_event_decoder_returns_correlated_protocol_neutral_part_update() -> None: + update = normalize_v1_event( + { + "type": "message.part.updated", + "properties": { + "sessionID": "ses-1", + "messageID": "msg-1", + "part": { + "id": "prt-1", + "type": "text", + "role": "assistant", + "text": "answer", + "sessionID": "ses-1", + "messageID": "msg-1", + }, + }, + } + ) + + assert update is not None + assert update.kind == UpdateKind.PART_UPDATED + assert update.session_id == "ses-1" + assert update.message_id == "msg-1" + assert update.part_id == "prt-1" + assert update.part_type == "text" + assert update.role == "agent" + assert update.snapshot == "answer" + + +@pytest.mark.asyncio +async def test_submit_prompt_separates_v1_receipt_from_terminal_outcome(monkeypatch) -> None: + client = OpencodeUpstreamClient(make_settings(test_bearer_token="t-1")) + message = OpencodeMessage( + text="completed output", + session_id="ses-1", + message_id="assistant-msg-1", + raw={"info": {"tokens": {"input": 2, "output": 3}}, "parts": []}, + ) + send_message = AsyncMock(return_value=message) + monkeypatch.setattr(client, "send_message", send_message) + + submission = await client.submit_prompt("ses-1", "input") + + assert submission.receipt.session.id == "ses-1" + assert submission.receipt.message_id is None + assert submission.outcome.state == ExecutionState.COMPLETED + assert submission.outcome.message_id == "assistant-msg-1" + assert submission.outcome.output == "completed output" + assert submission.outcome.usage == {"input_tokens": 2, "output_tokens": 3, "total_tokens": 5} + assert submission.outcome.response is not None + assert submission.outcome.response["parts"] == [] + assert send_message.await_args is not None + assert send_message.await_args.args == ("ses-1", "input") + assert send_message.await_args.kwargs["parts"] is None + assert "timeout_override" in send_message.await_args.kwargs + await client.close() + + +@pytest.mark.asyncio +async def test_submit_prompt_preserves_terminal_v1_error_in_outcome(monkeypatch) -> None: + client = OpencodeUpstreamClient(make_settings(test_bearer_token="t-1")) + message = OpencodeMessage( + text="", + session_id="ses-1", + message_id="assistant-msg-1", + raw={ + "info": { + "error": { + "name": "APIError", + "data": {"message": "upstream denied", "statusCode": 400}, + } + } + }, + ) + monkeypatch.setattr(client, "send_message", AsyncMock(return_value=message)) + + submission = await client.submit_prompt("ses-1", "input") + + assert submission.outcome.state == ExecutionState.FAILED + assert submission.outcome.error_state == ExecutionState.FAILED + assert submission.outcome.error_type == "UPSTREAM_BAD_REQUEST" + assert submission.outcome.upstream_status == 400 + assert "upstream denied" in (submission.outcome.error_message or "") + await client.close() + + +@pytest.mark.asyncio +async def test_stream_updates_exposes_normalized_values_not_v1_event_mapping(monkeypatch) -> None: + client = OpencodeUpstreamClient(make_settings(test_bearer_token="t-1")) + + async def stream_events(*_args, **_kwargs): + yield { + "type": "message.part.delta", + "properties": { + "sessionID": "ses-1", + "messageID": "msg-1", + "partID": "prt-1", + "field": "text", + "delta": "hello", + }, + } + + monkeypatch.setattr(client, "stream_events", stream_events) + + updates = [update async for update in client.stream_updates()] + + assert len(updates) == 1 + assert updates[0].kind == UpdateKind.PART_DELTA + assert updates[0].session_id == "ses-1" + assert updates[0].part_id == "prt-1" + assert updates[0].delta == "hello" + assert not hasattr(updates[0], "properties") + await client.close() From a2f8691ecf530715787de43b51f738448c698eac Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 11:32:05 +0100 Subject: [PATCH 02/30] fix(upstream): allow receipt-only submissions safely (#2) --- src/opencode_a2a/execution/coordinator.py | 21 +++++++--- src/opencode_a2a/upstream/contracts.py | 2 +- .../test_streaming_output_contract_core.py | 39 +++++++++++++++++++ tests/upstream/test_upstream_contracts.py | 2 + 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/opencode_a2a/execution/coordinator.py b/src/opencode_a2a/execution/coordinator.py index 29ede9f2..1c6b4e3c 100644 --- a/src/opencode_a2a/execution/coordinator.py +++ b/src/opencode_a2a/execution/coordinator.py @@ -22,7 +22,11 @@ TaskStatusUpdateEvent, ) -from ..opencode_upstream_client import UpstreamConcurrencyLimitError, UpstreamContractError +from ..opencode_upstream_client import ( + OpencodeMessage, + UpstreamConcurrencyLimitError, + UpstreamContractError, +) from ..upstream.contracts import ( ExecutionOutcome, PromptSubmission, @@ -160,15 +164,20 @@ async def run(self) -> None: if callable(submit_prompt) else None ) - response = ( - submission.outcome - if isinstance(submission, PromptSubmission) - else await self._executor._client.send_message( + response: ExecutionOutcome | OpencodeMessage + if isinstance(submission, PromptSubmission): + if submission.outcome is None: + raise RuntimeError( + "Upstream accepted the prompt without a terminal outcome; " + "asynchronous observation is not supported" + ) + response = submission.outcome + else: + response = await self._executor._client.send_message( self._session_id, prompt_text, **prompt_kwargs, ) - ) if self._pending_preferred_claim: await self._executor._session_manager.finalize_preferred_session_binding( diff --git a/src/opencode_a2a/upstream/contracts.py b/src/opencode_a2a/upstream/contracts.py index 1579654c..8b517951 100644 --- a/src/opencode_a2a/upstream/contracts.py +++ b/src/opencode_a2a/upstream/contracts.py @@ -50,7 +50,7 @@ class ExecutionOutcome: @dataclass(frozen=True) class PromptSubmission: receipt: PromptReceipt - outcome: ExecutionOutcome + outcome: ExecutionOutcome | None @dataclass(frozen=True) diff --git a/tests/execution/test_streaming_output_contract_core.py b/tests/execution/test_streaming_output_contract_core.py index 997c48d3..f15999a0 100644 --- a/tests/execution/test_streaming_output_contract_core.py +++ b/tests/execution/test_streaming_output_contract_core.py @@ -1,5 +1,6 @@ import asyncio from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest from a2a.server.agent_execution import RequestContext @@ -20,6 +21,7 @@ from opencode_a2a.execution.stream_state import _StreamOutputState from opencode_a2a.server.context_helpers import normalize_server_call_context from opencode_a2a.task_states import TERMINAL_TASK_STATES +from opencode_a2a.upstream.contracts import PromptReceipt, PromptSubmission, SessionRef from tests.support.helpers import ( DummyEventQueue, make_request_context, @@ -45,6 +47,43 @@ def _is_terminal_status_event(event: TaskStatusUpdateEvent) -> bool: ) +@pytest.mark.asyncio +async def test_receipt_only_prompt_submission_fails_without_resending() -> None: + client = DummyStreamingClient(stream_events_payload=[], response_text="") + submit_prompt = AsyncMock( + return_value=PromptSubmission( + receipt=PromptReceipt(session=SessionRef("ses-1")), + outcome=None, + ) + ) + send_message = AsyncMock() + client.submit_prompt = submit_prompt + client.send_message = send_message + executor = OpencodeAgentExecutor(client, streaming_enabled=False) + queue = DummyEventQueue() + + await executor.execute( + make_request_context( + task_id="task-receipt-only", + context_id="ctx-receipt-only", + text="hello", + ), + queue, + ) + + failed_tasks = [ + event + for event in queue.events + if isinstance(event, Task) and event.status.state == TaskState.TASK_STATE_FAILED + ] + assert submit_prompt.await_count == 1 + send_message.assert_not_awaited() + assert len(failed_tasks) == 1 + assert ( + "asynchronous observation is not supported" in failed_tasks[0].status.message.parts[0].text + ) + + @pytest.mark.asyncio async def test_streaming_accepts_file_input_without_breaking_contract() -> None: client = DummyStreamingClient( diff --git a/tests/upstream/test_upstream_contracts.py b/tests/upstream/test_upstream_contracts.py index b88fd570..a67852bb 100644 --- a/tests/upstream/test_upstream_contracts.py +++ b/tests/upstream/test_upstream_contracts.py @@ -54,6 +54,7 @@ async def test_submit_prompt_separates_v1_receipt_from_terminal_outcome(monkeypa submission = await client.submit_prompt("ses-1", "input") + assert submission.outcome is not None assert submission.receipt.session.id == "ses-1" assert submission.receipt.message_id is None assert submission.outcome.state == ExecutionState.COMPLETED @@ -89,6 +90,7 @@ async def test_submit_prompt_preserves_terminal_v1_error_in_outcome(monkeypatch) submission = await client.submit_prompt("ses-1", "input") + assert submission.outcome is not None assert submission.outcome.state == ExecutionState.FAILED assert submission.outcome.error_state == ExecutionState.FAILED assert submission.outcome.error_type == "UPSTREAM_BAD_REQUEST" From 767e70691d8a09624d25ae4c7ba44a477d3d9ab2 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 11:49:23 +0100 Subject: [PATCH 03/30] feat(upstream): add opt-in OpenCode V2 session transport (#3) --- docs/compatibility.md | 8 + docs/guide.md | 1 + src/opencode_a2a/config.py | 2 + src/opencode_a2a/execution/executor.py | 15 ++ src/opencode_a2a/jsonrpc/application.py | 8 +- src/opencode_a2a/server/agent_card.py | 29 ++- src/opencode_a2a/server/application.py | 14 +- src/opencode_a2a/server/middleware.py | 5 +- src/opencode_a2a/server/openapi.py | 7 +- src/opencode_a2a/upstream/v2.py | 230 ++++++++++++++++++ tests/config/test_settings.py | 27 ++ tests/execution/test_v2_prompt_failfast.py | 32 +++ .../test_jsonrpc_unsupported_method.py | 34 +++ tests/server/test_agent_card.py | 18 ++ tests/upstream/test_v2_upstream_adapter.py | 212 ++++++++++++++++ 15 files changed, 634 insertions(+), 8 deletions(-) create mode 100644 src/opencode_a2a/upstream/v2.py create mode 100644 tests/execution/test_v2_prompt_failfast.py create mode 100644 tests/upstream/test_v2_upstream_adapter.py diff --git a/docs/compatibility.md b/docs/compatibility.md index 39117a43..17e9bed1 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -9,6 +9,14 @@ This document defines the compatibility promises `opencode-a2a` currently uphold - Supported A2A protocol line: `1.0` - OpenCode runtime line: `1.18.x` (verified with `1.18.19`) +OpenCode V2 is an opt-in, experimental session-transport slice selected with +`OPENCODE_API_VERSION=v2`. It implements `/api/info`, `/api/location`, and +session create/get/list transport only; A2A prompt execution and all +OpenCode-specific JSON-RPC extensions remain unavailable. V1 stays the +default, and the adapter does not auto-detect or fall back between versions. +The V2 session transport was exercised against OpenCode `2.0.28`; that +experimental check does not add V2 to the supported runtime line above. + This is the single canonical place where the supported upstream OpenCode version line is declared. Keep it updated together with the live integration smoke check (`./scripts/live_opencode_smoke.sh`) and do not duplicate the version line elsewhere. The SDK-owned core JSON-RPC method set follows the supported SDK line and is locked by repository tests so SDK upgrades trigger an explicit compatibility review. diff --git a/docs/guide.md b/docs/guide.md index fb4bc0ce..7561bb27 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -29,6 +29,7 @@ Key variables to understand protocol behavior: - `A2A_STATIC_AUTH_CREDENTIALS`: required static credential registry in JSON array form. Supports multiple `bearer` / `basic` credentials, bearer-only required `principal`, optional `credential_id`, optional `capabilities`, and optional `enabled=false` for explicit disablement. - `OPENCODE_BASE_URL`: upstream OpenCode HTTP endpoint. Default: `http://127.0.0.1:4096`. In two-process deployments, set it explicitly. +- `OPENCODE_API_VERSION`: upstream API selector, `v1` (default) or `v2`. Selection is explicit; the service never detects or falls back to another API version. V2 is experimental session transport only: prompt execution and OpenCode-specific extensions are rejected/hidden until later support lands. - `OPENCODE_WORKSPACE_ROOT`: service-level default workspace root exposed to OpenCode when clients do not request a narrower directory override. - `A2A_ALLOW_DIRECTORY_OVERRIDE`: controls whether clients may pass `metadata.opencode.directory`. - `A2A_ENABLE_SESSION_SHELL`: gates high-risk JSON-RPC method `opencode.sessions.shell`. diff --git a/src/opencode_a2a/config.py b/src/opencode_a2a/config.py index 84f1775f..ea746cb4 100644 --- a/src/opencode_a2a/config.py +++ b/src/opencode_a2a/config.py @@ -37,6 +37,7 @@ OutsideWorkspaceAccess = Literal["unknown", "allowed", "disallowed", "custom"] TaskStoreBackend = Literal["memory", "database"] StaticAuthScheme = Literal["bearer", "basic"] +OpencodeApiVersion = Literal["v1", "v2"] def _parse_declared_list(value: Any) -> tuple[str, ...]: @@ -148,6 +149,7 @@ class Settings(BaseSettings): ) # OpenCode settings + opencode_api_version: OpencodeApiVersion = Field(default="v1", alias="OPENCODE_API_VERSION") opencode_base_url: str = Field(default="http://127.0.0.1:4096", alias="OPENCODE_BASE_URL") opencode_workspace_root: str | None = Field(default=None, alias="OPENCODE_WORKSPACE_ROOT") opencode_agent: str | None = Field(default=None, alias="OPENCODE_AGENT") diff --git a/src/opencode_a2a/execution/executor.py b/src/opencode_a2a/execution/executor.py index 19fffcd2..1826ce72 100644 --- a/src/opencode_a2a/execution/executor.py +++ b/src/opencode_a2a/execution/executor.py @@ -109,6 +109,21 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non ) return + if not getattr(self._client, "prompt_execution_supported", True): + await self._emit_error( + event_queue, + task_id=task_id, + context_id=context_id, + message=( + "OpenCode V2 prompt execution is not implemented; session transport is " + "experimental and prompt execution will be available in a later release." + ), + state=TaskState.TASK_STATE_FAILED, + error_type="UPSTREAM_UNSUPPORTED", + streaming_request=self._should_stream(context), + ) + return + call_context = context.call_context identity = (call_context.state.get("identity") if call_context else None) or "anonymous" credential_id = call_context.state.get("credential_id") if call_context else None diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index 21087464..701188f2 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -64,6 +64,7 @@ def __init__( upstream_client: OpencodeUpstreamClient, methods: dict[str, str], supported_methods: list[str], + extensions_enabled: bool = True, directory_resolver: Callable[[str | None], str | None] | None = None, session_claim: Callable[..., Awaitable[bool]] | None = None, session_claim_finalize: Callable[..., Awaitable[None]] | None = None, @@ -76,6 +77,7 @@ def __init__( super().__init__(request_handler=http_handler, **kwargs) self._http_handler = http_handler self._upstream_client = upstream_client + self._extensions_enabled = extensions_enabled self._method_session_status = methods["status"] self._method_list_sessions = methods["list_sessions"] self._method_get_session = methods["get_session"] @@ -421,7 +423,11 @@ async def handle_requests(self, request: Request) -> Response: except Exception: return await super().handle_requests(request) - extension_spec = self._extension_method_registry.resolve(base_request.method) + extension_spec = ( + self._extension_method_registry.resolve(base_request.method) + if self._extensions_enabled + else None + ) if extension_spec is None: return await self._handle_core_request( request, diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index e7032cc4..daf02cce 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -66,6 +66,11 @@ def _build_agent_card_description( include_detailed_contracts: bool, ) -> str: base = (settings.a2a_description or "").strip() or "OpenCode A2A runtime." + if settings.opencode_api_version == "v2": + return ( + f"{base} OpenCode V2 session transport is experimental; A2A prompt execution and " + "OpenCode-specific extensions are not implemented." + ) if not include_detailed_contracts: public_parts: list[str] = [ base, @@ -356,6 +361,20 @@ def _build_agent_skills( capability_snapshot: JsonRpcCapabilitySnapshot, include_detailed_contracts: bool, ) -> list[AgentSkill]: + if settings.opencode_api_version == "v2": + return [ + AgentSkill( + id="v2-session-transport", + name="OpenCode V2 Session Transport", + description=( + "Experimental upstream session transport for adapter integrations; " + "no A2A session or prompt operation is currently available." + ), + input_modes=list(_JSON_RPC_MODES), + output_modes=list(_JSON_RPC_MODES), + tags=["experimental", "transport"], + ) + ] if not include_detailed_contracts: return [ AgentSkill( @@ -533,9 +552,13 @@ def build_agent_card( capabilities=AgentCapabilities( streaming=True, extended_agent_card=True, - extensions=_build_agent_extensions( - runtime_profile=runtime_profile, - include_detailed_contracts=include_detailed_contracts, + extensions=( + [] + if settings.opencode_api_version == "v2" + else _build_agent_extensions( + runtime_profile=runtime_profile, + include_detailed_contracts=include_detailed_contracts, + ) ), ), skills=_build_agent_skills( diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index 431a8850..9b5fc4e2 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -20,6 +20,7 @@ from ..a2a_protocol import ( AGENT_CARD_WELL_KNOWN_PATH, + CORE_JSONRPC_METHODS, EXTENDED_AGENT_CARD_PATH, ) from ..config import Settings @@ -38,6 +39,7 @@ from ..profile.runtime import build_runtime_profile from ..protocol_versions import A2A_PROTOCOL_VERSION from ..trace_context import install_log_record_factory +from ..upstream.v2 import V2UpstreamAdapter from .agent_card import ( build_agent_card, ) @@ -172,7 +174,10 @@ def create_app(settings: Settings) -> FastAPI: settings, engine=database_engine, ) - upstream_client = OpencodeUpstreamClient( + upstream_client_type = ( + V2UpstreamAdapter if settings.opencode_api_version == "v2" else OpencodeUpstreamClient + ) + upstream_client = upstream_client_type( settings, interrupt_request_repository=runtime_state_runtime.interrupt_request_repository, ) @@ -218,7 +223,12 @@ def create_app(settings: Settings) -> FastAPI: http_handler=handler, context_builder=context_builder, upstream_client=upstream_client, - supported_methods=capability_snapshot.supported_jsonrpc_methods(), + supported_methods=( + list(CORE_JSONRPC_METHODS) + if settings.opencode_api_version == "v2" + else capability_snapshot.supported_jsonrpc_methods() + ), + extensions_enabled=settings.opencode_api_version == "v1", directory_resolver=( partial( executor._sandbox_policy.resolve_directory, diff --git a/src/opencode_a2a/server/middleware.py b/src/opencode_a2a/server/middleware.py index 176693cf..fe61246b 100644 --- a/src/opencode_a2a/server/middleware.py +++ b/src/opencode_a2a/server/middleware.py @@ -494,7 +494,10 @@ def _resolve_activated_extensions(request: Request) -> tuple[str, ...]: return () path = request.url.path - if path == AGENT_CARD_WELL_KNOWN_PATH: + supported_extensions: tuple[str, ...] + if settings.opencode_api_version == "v2": + supported_extensions = () + elif path == AGENT_CARD_WELL_KNOWN_PATH: supported_extensions = PUBLIC_EXTENSION_URIS elif path == EXTENDED_AGENT_CARD_PATH: supported_extensions = ALL_EXTENSION_URIS diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index 4e305603..daf5b3e0 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -244,7 +244,6 @@ def _patch_jsonrpc_openapi_contract( *, runtime_profile: RuntimeProfile, ) -> None: - del settings session_binding = build_session_binding_extension_params( runtime_profile=runtime_profile, ) @@ -316,6 +315,12 @@ def custom_openapi() -> dict[str, Any]: ), }, } + if settings.opencode_api_version == "v2": + post["description"] = ( + "OpenCode V2 session transport is experimental. A2A prompt " + "execution and extension methods are not implemented." + ) + post.pop("x-a2a-extension-contracts", None) request_body = post.setdefault("requestBody", {}) if isinstance(request_body, dict): diff --git a/src/opencode_a2a/upstream/v2.py b/src/opencode_a2a/upstream/v2.py new file mode 100644 index 00000000..85448744 --- /dev/null +++ b/src/opencode_a2a/upstream/v2.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Mapping, Sequence +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ..config import Settings +from ..opencode_upstream_client import ( + OpencodeMessage, + OpencodeUpstreamClient, + UpstreamContractError, +) +from ..sandbox_policy import SandboxPolicy +from ..server.state_store import InterruptRequestRepository +from .contracts import PromptSubmission + + +class UnsupportedUpstreamOperationError(RuntimeError): + """Raised when the selected upstream protocol has no implemented operation.""" + + +class V2UpstreamAdapter(OpencodeUpstreamClient): + """Explicit V2 session transport using the shared V1 HTTP facade infrastructure.""" + + prompt_execution_supported = False + + def __init__( + self, + settings: Settings, + *, + interrupt_request_repository: InterruptRequestRepository | None = None, + ) -> None: + super().__init__(settings, interrupt_request_repository=interrupt_request_repository) + self._v2_server_info: Mapping[str, Any] | None = None + self._v2_server_info_lock = asyncio.Lock() + + def _resolve_location(self, directory: str | None, workspace_id: str | None) -> str: + if workspace_id is not None and workspace_id.strip(): + raise UnsupportedUpstreamOperationError( + "OpenCode V2 session transport does not support V1 workspace IDs." + ) + return ( + SandboxPolicy.from_settings( + self.settings, + workspace_root=self.directory, + ).resolve_directory(directory, default_directory=self.directory) + or "" + ) + + @staticmethod + def _unwrap_data(payload: Any, *, endpoint: str) -> Any: + if not isinstance(payload, Mapping) or "data" not in payload: + raise UpstreamContractError( + f"OpenCode V2 {endpoint} response must contain a data envelope" + ) + return payload["data"] + + async def _get_v2_json( + self, + path: str, + *, + endpoint: str, + params: Mapping[str, Any] | None = None, + ) -> Any: + if endpoint != "/api/info": + await self.get_server_info() + try: + return await self._get_json(path, endpoint=endpoint, params=params) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + raise UpstreamContractError( + f"OpenCode V2 {endpoint} returned HTTP 404; verify the upstream is an " + "OpenCode V2 server and the requested location/session exists" + ) from exc + raise + + async def _post_v2_json( + self, + path: str, + *, + endpoint: str, + json_body: Any, + ) -> Any: + await self.get_server_info() + try: + return await self._post_json( + path, + endpoint=endpoint, + json_body=json_body, + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + raise UpstreamContractError( + f"OpenCode V2 {endpoint} returned HTTP 404; verify the upstream is an " + "OpenCode V2 server and the requested location/session exists" + ) from exc + raise + + async def get_server_info(self) -> Mapping[str, Any]: + async with self._v2_server_info_lock: + if self._v2_server_info is not None: + return self._v2_server_info + payload = await self._get_v2_json("/api/info", endpoint="/api/info") + version = payload.get("version") if isinstance(payload, Mapping) else None + if not isinstance(version, str): + raise UpstreamContractError( + "OpenCode V2 /api/info response must include a string version" + ) + if not version.startswith("2."): + raise UpstreamContractError( + f"OpenCode V2 was selected but /api/info reports version {version!r}" + ) + server_info = cast(Mapping[str, Any], payload) + self._v2_server_info = server_info + return server_info + + async def get_location(self, *, directory: str | None = None) -> Mapping[str, Any]: + resolved_directory = self._resolve_location(directory, None) + payload = await self._get_v2_json( + "/api/location", + endpoint="/api/location", + params={"location[directory]": resolved_directory}, + ) + if not isinstance(payload, Mapping): + raise UpstreamContractError("OpenCode V2 /api/location response must be an object") + return payload + + async def create_session( + self, + title: str | None = None, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> str: + resolved_directory = self._resolve_location(directory, workspace_id) + payload: dict[str, Any] = {"location": {"directory": resolved_directory}} + if title: + payload["title"] = title + response = await self._post_v2_json( + "/api/session", + endpoint="/api/session", + json_body=payload, + ) + data = self._unwrap_data(response, endpoint="/api/session") + session_id = data.get("id") if isinstance(data, Mapping) else None + if not isinstance(session_id, str) or not session_id.strip(): + raise UpstreamContractError("OpenCode V2 /api/session data is missing session id") + return session_id + + async def get_session( + self, + session_id: str, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> Any: + normalized_id = session_id.strip() + if not normalized_id: + raise ValueError("session_id must not be empty") + resolved_directory = self._resolve_location(directory, workspace_id) + path = f"/api/session/{quote(normalized_id, safe='')}" + response = await self._get_v2_json( + path, + endpoint="/api/session/{sessionID}", + params={"location[directory]": resolved_directory}, + ) + return self._unwrap_data(response, endpoint="/api/session/{sessionID}") + + async def list_sessions( + self, + *, + params: dict[str, Any] | None = None, + directory: str | None = None, + workspace_id: str | None = None, + ) -> Any: + resolved_directory = self._resolve_location(directory, workspace_id) + query: dict[str, Any] = {"location[directory]": resolved_directory} + if params: + if any( + key in {"directory", "workspace", "location"} or key.startswith("location[") + for key in params + ): + raise ValueError("V2 session list location is selected by the location parameter") + query.update(params) + response = await self._get_v2_json( + "/api/session", + endpoint="/api/session", + params=query, + ) + data = self._unwrap_data(response, endpoint="/api/session") + if not isinstance(data, list): + raise UpstreamContractError("OpenCode V2 /api/session data must be an array") + return {"data": data, **{key: value for key, value in response.items() if key != "data"}} + + async def send_message( + self, + session_id: str, + text: str | None = None, + *, + parts: Sequence[Mapping[str, Any]] | None = None, + directory: str | None = None, + workspace_id: str | None = None, + model_override: Mapping[str, Any] | None = None, + timeout_override: float | None | object = ..., + ) -> OpencodeMessage: + del session_id, text, parts, directory, workspace_id, model_override, timeout_override + raise UnsupportedUpstreamOperationError( + "OpenCode V2 prompt execution is not implemented; use OPENCODE_API_VERSION=v1 " + "or wait for the V2 execution support release." + ) + + async def submit_prompt( + self, + session_id: str, + text: str | None = None, + *, + parts: Sequence[Mapping[str, Any]] | None = None, + directory: str | None = None, + workspace_id: str | None = None, + model_override: Mapping[str, Any] | None = None, + timeout_override: float | None | object = ..., + ) -> PromptSubmission: + del session_id, text, parts, directory, workspace_id, model_override, timeout_override + raise UnsupportedUpstreamOperationError( + "OpenCode V2 prompt execution is not implemented; use OPENCODE_API_VERSION=v1 " + "or wait for the V2 execution support release." + ) diff --git a/tests/config/test_settings.py b/tests/config/test_settings.py index b9ef5cd5..65fcf041 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -26,6 +26,7 @@ def test_settings_missing_required(): def test_settings_use_bounded_upstream_defaults() -> None: settings = make_settings() + assert settings.opencode_api_version == "v1" assert settings.opencode_timeout_stream == 900.0 assert settings.opencode_max_concurrent_requests == 32 assert settings.opencode_max_concurrent_streams == 8 @@ -151,6 +152,32 @@ def test_settings_opencode_auth_defaults_to_opencode_username_without_password() assert settings.opencode_auth_password is None +@pytest.mark.parametrize("version", ("v1", "v2")) +def test_settings_accepts_explicit_opencode_api_version(version: str) -> None: + env = { + "OPENCODE_API_VERSION": version, + "A2A_STATIC_AUTH_CREDENTIALS": json.dumps( + [{"scheme": "bearer", "token": "test-token", "principal": "automation"}] + ), + } + with mock.patch.dict(os.environ, env, clear=True): + settings = Settings() + + assert settings.opencode_api_version == version + + +def test_settings_rejects_unknown_opencode_api_version() -> None: + env = { + "OPENCODE_API_VERSION": "auto", + "A2A_STATIC_AUTH_CREDENTIALS": json.dumps( + [{"scheme": "bearer", "token": "test-token", "principal": "automation"}] + ), + } + with mock.patch.dict(os.environ, env, clear=True): + with pytest.raises(ValidationError, match="OPENCODE_API_VERSION"): + Settings() + + def test_settings_opencode_auth_repr_hides_password() -> None: settings = make_settings( opencode_auth_username="opencode", diff --git a/tests/execution/test_v2_prompt_failfast.py b/tests/execution/test_v2_prompt_failfast.py new file mode 100644 index 00000000..fe3bd53e --- /dev/null +++ b/tests/execution/test_v2_prompt_failfast.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from a2a.types import Task, TaskState + +from opencode_a2a.execution.executor import OpencodeAgentExecutor +from tests.support.helpers import DummyEventQueue, make_request_context +from tests.support.settings import make_settings + + +@pytest.mark.asyncio +async def test_v2_prompt_is_failed_before_session_or_upstream_side_effect() -> None: + client = AsyncMock() + client.settings = make_settings(opencode_api_version="v2") + client.directory = "/tmp/opencode-v2-workspace" + client.prompt_execution_supported = False + executor = OpencodeAgentExecutor(client, streaming_enabled=True) + queue = DummyEventQueue() + + await executor.execute( + make_request_context(task_id="task-v2", context_id="ctx-v2", text="hello"), + queue, + ) + + tasks = [event for event in queue.events if isinstance(event, Task)] + assert tasks + assert tasks[-1].status.state == TaskState.TASK_STATE_FAILED + assert "V2 prompt execution is not implemented" in tasks[-1].status.message.parts[0].text + client.create_session.assert_not_awaited() + client.send_message.assert_not_awaited() diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index 9599c539..75850183 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -1,8 +1,10 @@ import httpx import pytest +from opencode_a2a.contracts.extensions import SESSION_BINDING_EXTENSION_URI from opencode_a2a.protocol_versions import A2A_PROTOCOL_VERSION from opencode_a2a.server.application import create_app +from opencode_a2a.upstream.v2 import V2UpstreamAdapter from tests.support.settings import make_settings @@ -36,6 +38,38 @@ async def test_unsupported_method_returns_unified_error() -> None: assert data["protocolVersion"] == A2A_PROTOCOL_VERSION +@pytest.mark.asyncio +async def test_v2_rejects_unimplemented_opencode_extension_methods() -> None: + app = create_app(make_settings(opencode_api_version="v2")) + assert isinstance(app.state.upstream_client, V2UpstreamAdapter) + openapi = app.openapi() + assert "x-a2a-extension-contracts" not in openapi["paths"]["/"]["post"] + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/", + headers={ + "Authorization": "Bearer test-token", + "A2A-Extensions": SESSION_BINDING_EXTENSION_URI, + }, + json={ + "jsonrpc": "2.0", + "id": 123, + "method": "opencode.sessions.prompt_async", + "params": {"sessionID": "ses-1", "parts": [{"type": "text", "text": "hello"}]}, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["error"]["code"] == -32601 + assert body["error"]["message"] == "Method not found" + assert "opencode.sessions.prompt_async" not in body["error"]["data"]["supportedMethods"] + assert "opencode.sessions.list" not in body["error"]["data"]["supportedMethods"] + assert "A2A-Extensions" not in response.headers + + @pytest.mark.asyncio async def test_unsupported_method_uses_requested_protocol_version() -> None: settings = make_settings(test_bearer_token="test-token") diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 38c70400..0ae21502 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -60,6 +60,24 @@ def test_agent_card_description_reflects_actual_transport_capabilities() -> None assert skills_by_id["opencode.interrupt.callback"].output_modes == ["application/json"] +def test_v2_agent_card_does_not_advertise_unimplemented_chat_or_extensions() -> None: + card = build_agent_card(make_settings(opencode_api_version="v2")) + extended_card = build_agent_card( + make_settings(opencode_api_version="v2"), + include_detailed_contracts=True, + ) + + assert "session transport is experimental" in card.description + assert ( + "prompt execution and OpenCode-specific extensions are not implemented" in card.description + ) + assert card.capabilities.extensions == [] + assert extended_card.capabilities.extensions == [] + assert [skill.id for skill in card.skills] == ["v2-session-transport"] + assert [skill.id for skill in extended_card.skills] == ["v2-session-transport"] + assert "no A2A session or prompt operation is currently available" in card.skills[0].description + + def test_agent_card_declares_optional_basic_auth_when_configured() -> None: card = build_agent_card( make_settings( diff --git a/tests/upstream/test_v2_upstream_adapter.py b/tests/upstream/test_v2_upstream_adapter.py new file mode 100644 index 00000000..07a4e922 --- /dev/null +++ b/tests/upstream/test_v2_upstream_adapter.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +from opencode_a2a.opencode_upstream_client import UpstreamContractError +from opencode_a2a.upstream.v2 import UnsupportedUpstreamOperationError, V2UpstreamAdapter +from tests.support.settings import make_settings + + +def _make_client( + monkeypatch: pytest.MonkeyPatch, + handler, + *, + workspace_root: str, +) -> tuple[V2UpstreamAdapter, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + async def capture(request: httpx.Request) -> httpx.Response: + requests.append(request) + return await handler(request) + + client = V2UpstreamAdapter( + make_settings( + opencode_workspace_root=workspace_root, + opencode_auth_username="gateway", + opencode_auth_password="test-password", # pragma: allowlist secret + ) + ) + monkeypatch.setattr(client._client, "_transport", httpx.MockTransport(capture)) + return client, requests + + +@pytest.mark.asyncio +async def test_v2_session_calls_use_documented_routes_locations_and_data_envelopes( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + async def respond(request: httpx.Request) -> httpx.Response: + if request.url.path == "/api/info": + payload: Any = {"version": "2.0.28", "pid": 123, "urls": []} + elif request.url.path == "/api/location": + payload = {"directory": str(workspace)} + elif request.method == "POST": + payload = {"data": {"id": "ses-created", "title": "topic"}} + elif request.url.path == "/api/session": + payload = {"data": [{"id": "ses-created"}], "cursor": {"next": "cursor-2"}} + else: + payload = {"data": {"id": "ses-created", "title": "topic"}} + return httpx.Response(200, json=payload) + + client, requests = _make_client(monkeypatch, respond, workspace_root=str(workspace)) + try: + identity = await client.get_server_info() + location = await client.get_location() + session_id = await client.create_session("topic") + session = await client.get_session(session_id) + page = await client.list_sessions(directory=str(workspace), params={"limit": 5}) + finally: + await client.close() + + assert identity["version"] == "2.0.28" + assert location["directory"] == str(workspace) + assert session_id == "ses-created" + assert session["title"] == "topic" + assert page == {"data": [{"id": "ses-created"}], "cursor": {"next": "cursor-2"}} + assert [request.url.path for request in requests] == [ + "/api/info", + "/api/location", + "/api/session", + "/api/session/ses-created", + "/api/session", + ] + assert requests[1].url.params["location[directory]"] == str(workspace) + assert json.loads(requests[2].content) == { + "location": {"directory": str(workspace)}, + "title": "topic", + } + assert requests[3].url.params["location[directory]"] == str(workspace) + assert requests[4].url.params["location[directory]"] == str(workspace) + assert requests[4].url.params["limit"] == "5" + assert requests[0].headers["authorization"].startswith("Basic ") + + +@pytest.mark.asyncio +async def test_v2_rejects_outside_workspace_and_v1_workspace_id_before_http( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + async def respond(_request: httpx.Request) -> httpx.Response: + raise AssertionError("unauthorized request reached the upstream") + + client, requests = _make_client(monkeypatch, respond, workspace_root=str(workspace)) + escaped = tmp_path / "outside" + escaped.mkdir() + (workspace / "escape").symlink_to(escaped, target_is_directory=True) + try: + with pytest.raises(ValueError, match="outside the allowed workspace"): + await client.create_session("topic", directory=str(tmp_path)) + with pytest.raises(ValueError, match="outside the allowed workspace"): + await client.create_session("topic", directory=str(workspace / "escape")) + with pytest.raises(UnsupportedUpstreamOperationError, match="workspace IDs"): + await client.create_session("topic", workspace_id="workspace-1") + with pytest.raises(ValueError, match="location is selected"): + await client.list_sessions(params={"location[directory]": str(tmp_path)}) + finally: + await client.close() + + assert requests == [] + + +@pytest.mark.asyncio +async def test_v2_requires_data_envelope_and_reports_mismatched_route( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + async def missing_envelope(_request: httpx.Request) -> httpx.Response: + if _request.url.path == "/api/info": + return httpx.Response(200, json={"version": "2.0.28"}) + return httpx.Response(200, json={"id": "ses-unwrapped"}) + + client, _ = _make_client(monkeypatch, missing_envelope, workspace_root=str(workspace)) + try: + with pytest.raises(UpstreamContractError, match="data envelope"): + await client.create_session("topic") + finally: + await client.close() + + async def html_response(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, text="wrong upstream", headers={"content-type": "text/html"} + ) + + client, _ = _make_client(monkeypatch, html_response, workspace_root=str(workspace)) + try: + with pytest.raises(UpstreamContractError, match="returned non-JSON response"): + await client.get_server_info() + finally: + await client.close() + + async def not_found(_request: httpx.Request) -> httpx.Response: + return httpx.Response(404, json={"error": "not found"}) + + client, _ = _make_client(monkeypatch, not_found, workspace_root=str(workspace)) + try: + with pytest.raises(UpstreamContractError, match="verify the upstream is an OpenCode V2"): + await client.get_server_info() + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_v2_prompt_methods_fail_without_http_side_effect( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + async def respond(_request: httpx.Request) -> httpx.Response: + raise AssertionError("prompt endpoint must not be reached") + + client, requests = _make_client(monkeypatch, respond, workspace_root=str(workspace)) + try: + with pytest.raises( + UnsupportedUpstreamOperationError, + match="prompt execution is not implemented", + ): + await client.send_message("ses-1", "hello") + with pytest.raises( + UnsupportedUpstreamOperationError, + match="prompt execution is not implemented", + ): + await client.submit_prompt("ses-1", "hello") + finally: + await client.close() + + assert requests == [] + + +@pytest.mark.asyncio +async def test_v2_refuses_to_use_session_route_when_info_reports_v1( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + async def v1_server(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/info" + return httpx.Response(200, json={"version": "1.18.19"}) + + client, requests = _make_client(monkeypatch, v1_server, workspace_root=str(workspace)) + try: + with pytest.raises(UpstreamContractError, match="V2 was selected but /api/info reports"): + await client.create_session("topic") + finally: + await client.close() + + assert [request.url.path for request in requests] == ["/api/info"] From a1362e8bd122cec190655b062d97415693a47978 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 11:52:25 +0100 Subject: [PATCH 04/30] fix(card): avoid advertising V2 execution capabilities (#3) --- src/opencode_a2a/server/agent_card.py | 18 ++++++++---------- tests/server/test_agent_card.py | 15 ++++++++++----- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index daf02cce..e6cf3eaf 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -68,8 +68,8 @@ def _build_agent_card_description( base = (settings.a2a_description or "").strip() or "OpenCode A2A runtime." if settings.opencode_api_version == "v2": return ( - f"{base} OpenCode V2 session transport is experimental; A2A prompt execution and " - "OpenCode-specific extensions are not implemented." + f"{base} Only internal experimental OpenCode V2 session transport exists; " + "A2A message execution and OpenCode-specific extensions are unsupported." ) if not include_detailed_contracts: public_parts: list[str] = [ @@ -364,15 +364,13 @@ def _build_agent_skills( if settings.opencode_api_version == "v2": return [ AgentSkill( - id="v2-session-transport", - name="OpenCode V2 Session Transport", + id="a2a-execution-unavailable", + name="A2A Execution Status", description=( - "Experimental upstream session transport for adapter integrations; " - "no A2A session or prompt operation is currently available." + "Informational only: internal experimental OpenCode V2 session transport " + "exists, but no A2A message or session operation is available." ), - input_modes=list(_JSON_RPC_MODES), - output_modes=list(_JSON_RPC_MODES), - tags=["experimental", "transport"], + tags=["status"], ) ] if not include_detailed_contracts: @@ -550,7 +548,7 @@ def build_agent_card( default_input_modes=list(_CHAT_INPUT_MODES), default_output_modes=list(_CHAT_OUTPUT_MODES), capabilities=AgentCapabilities( - streaming=True, + streaming=settings.opencode_api_version == "v1", extended_agent_card=True, extensions=( [] diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 0ae21502..8de60e0e 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -67,15 +67,20 @@ def test_v2_agent_card_does_not_advertise_unimplemented_chat_or_extensions() -> include_detailed_contracts=True, ) - assert "session transport is experimental" in card.description + assert "Only internal experimental OpenCode V2 session transport exists" in card.description assert ( - "prompt execution and OpenCode-specific extensions are not implemented" in card.description + "A2A message execution and OpenCode-specific extensions are unsupported" in card.description ) + assert card.capabilities.streaming is False + assert extended_card.capabilities.streaming is False assert card.capabilities.extensions == [] assert extended_card.capabilities.extensions == [] - assert [skill.id for skill in card.skills] == ["v2-session-transport"] - assert [skill.id for skill in extended_card.skills] == ["v2-session-transport"] - assert "no A2A session or prompt operation is currently available" in card.skills[0].description + for v2_card in (card, extended_card): + assert [skill.id for skill in v2_card.skills] == ["a2a-execution-unavailable"] + skill = v2_card.skills[0] + assert "no A2A message or session operation is available" in skill.description + assert skill.input_modes == [] + assert skill.output_modes == [] def test_agent_card_declares_optional_basic_auth_when_configured() -> None: From 5d832336b6b5475c0086f359835f5d3e47c09a0a Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 11:54:43 +0100 Subject: [PATCH 05/30] test(upstream): cover V2 session status failures (#3) --- docs/compatibility.md | 4 ++ tests/upstream/test_v2_upstream_adapter.py | 46 ++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs/compatibility.md b/docs/compatibility.md index 17e9bed1..f235787d 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -16,6 +16,10 @@ OpenCode-specific JSON-RPC extensions remain unavailable. V1 stays the default, and the adapter does not auto-detect or fall back between versions. The V2 session transport was exercised against OpenCode `2.0.28`; that experimental check does not add V2 to the supported runtime line above. +OpenCode V2 allows `external_directory` by default. Before any future release of +V2 prompt execution, operators must configure an explicit restrictive upstream +V2 `permissions` policy. The current session-only slice does not perform model +execution. This is the single canonical place where the supported upstream OpenCode version line is declared. Keep it updated together with the live integration smoke check (`./scripts/live_opencode_smoke.sh`) and do not duplicate the version line elsewhere. diff --git a/tests/upstream/test_v2_upstream_adapter.py b/tests/upstream/test_v2_upstream_adapter.py index 07a4e922..5b25636c 100644 --- a/tests/upstream/test_v2_upstream_adapter.py +++ b/tests/upstream/test_v2_upstream_adapter.py @@ -161,6 +161,52 @@ async def not_found(_request: httpx.Request) -> httpx.Response: await client.close() +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", (401, 409)) +async def test_v2_session_http_status_errors_are_typed_without_leaking_request_data( + status_code: int, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + request_title = "private-session-request-title" + observed_requests: list[httpx.Request] = [] + + async def respond(request: httpx.Request) -> httpx.Response: + if request.url.path == "/api/info": + return httpx.Response(200, json={"version": "2.0.28"}) + observed_requests.append(request) + echoed_request = request.content.decode() + echoed_authorization = request.headers.get("authorization", "") + return httpx.Response( + status_code, + json={ + "message": "upstream rejected request", + "request": echoed_request, + "authorization": echoed_authorization, + }, + ) + + client, _ = _make_client(monkeypatch, respond, workspace_root=str(workspace)) + try: + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await client.create_session(request_title) + finally: + await client.close() + + error = exc_info.value + message = str(error) + assert error.response.status_code == status_code + assert error.request.url.path == "/api/session" + assert len(observed_requests) == 1 + assert request_title.encode() in observed_requests[0].content + assert "Basic " in observed_requests[0].headers["authorization"] + assert request_title not in message + assert observed_requests[0].headers["authorization"] not in message + assert "test-password" not in message + + @pytest.mark.asyncio async def test_v2_prompt_methods_fail_without_http_side_effect( monkeypatch: pytest.MonkeyPatch, From 73ffcd8ca97170f6e12caf91cd7e0c43d7a3ef14 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 13:17:23 +0100 Subject: [PATCH 06/30] fix(execution): enforce upstream outcome safety --- src/opencode_a2a/execution/coordinator.py | 68 +++++++---- tests/execution/test_agent_errors.py | 9 ++ tests/execution/test_cancellation.py | 4 + tests/execution/test_session_ownership.py | 4 + .../test_streaming_output_contract_core.py | 110 +++++++++++++++++- 5 files changed, 173 insertions(+), 22 deletions(-) diff --git a/src/opencode_a2a/execution/coordinator.py b/src/opencode_a2a/execution/coordinator.py index 1c6b4e3c..e0622591 100644 --- a/src/opencode_a2a/execution/coordinator.py +++ b/src/opencode_a2a/execution/coordinator.py @@ -29,6 +29,7 @@ ) from ..upstream.contracts import ( ExecutionOutcome, + ExecutionState, PromptSubmission, UpstreamAdapter, ) @@ -56,6 +57,7 @@ from .executor import OpencodeAgentExecutor logger = logging.getLogger(__name__) +_MISSING = object() @dataclass(frozen=True) @@ -158,26 +160,34 @@ async def run(self) -> None: prompt_kwargs["parts"] = turn_request_parts upstream: UpstreamAdapter = self._executor._client - submit_prompt = getattr(upstream, "submit_prompt", None) - submission = ( - await submit_prompt(self._session_id, prompt_text, **prompt_kwargs) - if callable(submit_prompt) - else None - ) + submit_prompt = getattr(upstream, "submit_prompt", _MISSING) response: ExecutionOutcome | OpencodeMessage - if isinstance(submission, PromptSubmission): + if submit_prompt is _MISSING: + response = await self._executor._client.send_message( + self._session_id, + prompt_text, + **prompt_kwargs, + ) + else: + if not callable(submit_prompt): + raise UpstreamContractError( + "OpenCode submit_prompt must be callable when provided" + ) + submission = await submit_prompt( + self._session_id, + prompt_text, + **prompt_kwargs, + ) + if not isinstance(submission, PromptSubmission): + raise UpstreamContractError( + "OpenCode submit_prompt must return PromptSubmission" + ) if submission.outcome is None: raise RuntimeError( "Upstream accepted the prompt without a terminal outcome; " "asynchronous observation is not supported" ) response = submission.outcome - else: - response = await self._executor._client.send_message( - self._session_id, - prompt_text, - **prompt_kwargs, - ) if self._pending_preferred_claim: await self._executor._session_manager.finalize_preferred_session_binding( @@ -187,6 +197,13 @@ async def run(self) -> None: ) self._pending_preferred_claim = False + if ( + isinstance(response, ExecutionOutcome) + and response.state != ExecutionState.COMPLETED + ): + await self._handle_response(response) + break + response_payload = ( dict(response.response or {}) if isinstance(response, ExecutionOutcome) @@ -348,19 +365,28 @@ async def _handle_response(self, response: Any) -> None: if isinstance(response, ExecutionOutcome) else response.raw ) - response_error = ( - _UpstreamInBandError( - error_type=response.error_type, - state=_task_state_from_execution_state(response.error_state), - message=response.error_message, + response_error: _UpstreamInBandError | None + if isinstance(response, ExecutionOutcome) and response.state != ExecutionState.COMPLETED: + response_error = _UpstreamInBandError( + error_type=response.error_type or "UPSTREAM_EXECUTION_ERROR", + state=_task_state_from_execution_state(response.state), + message=response.error_message or "OpenCode execution did not complete.", upstream_status=response.upstream_status, ) - if isinstance(response, ExecutionOutcome) + elif ( + isinstance(response, ExecutionOutcome) and response.error_type is not None and response.error_state is not None and response.error_message is not None - else _extract_upstream_error_from_response(response_payload) - ) + ): + response_error = _UpstreamInBandError( + error_type=response.error_type, + state=_task_state_from_execution_state(response.error_state), + message=response.error_message, + upstream_status=response.upstream_status, + ) + else: + response_error = _extract_upstream_error_from_response(response_payload) resolved_token_usage = _merge_token_usage( response.usage if isinstance(response, ExecutionOutcome) diff --git a/tests/execution/test_agent_errors.py b/tests/execution/test_agent_errors.py index 8d116d28..877491b2 100644 --- a/tests/execution/test_agent_errors.py +++ b/tests/execution/test_agent_errors.py @@ -160,6 +160,7 @@ async def test_execute_http_error_maps_to_task_error_type_and_state( exc = httpx.HTTPStatusError("upstream error", request=request, response=response) client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -204,6 +205,7 @@ async def test_streaming_execute_http_error_emits_status_update_with_metadata(ca exc = httpx.HTTPStatusError("upstream error", request=request, response=response) client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -260,6 +262,7 @@ async def test_streaming_execute_upstream_backpressure_emits_status_update_with_ caplog, ) -> None: client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -318,6 +321,7 @@ async def create_session(title: str | None = None, *, directory: str | None = No @pytest.mark.asyncio async def test_execute_upstream_payload_error_maps_to_task_error_type() -> None: client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -360,6 +364,7 @@ async def create_session(title: str | None = None, *, directory: str | None = No @pytest.mark.asyncio async def test_execute_upstream_backpressure_maps_to_task_error_type() -> None: client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -403,6 +408,7 @@ async def create_session(title: str | None = None, *, directory: str | None = No @pytest.mark.asyncio async def test_execute_response_info_error_maps_to_task_failed_state() -> None: client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -463,6 +469,7 @@ async def test_streaming_execute_response_info_error_emits_failed_status_and_err None ): client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -532,6 +539,7 @@ async def stream_events(stop_event=None, *, directory: str | None = None): # no @pytest.mark.asyncio async def test_streaming_execute_session_error_emits_failed_status() -> None: client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory @@ -606,6 +614,7 @@ async def stream_events(stop_event=None, *, directory: str | None = None): # no @pytest.mark.asyncio async def test_streaming_execute_message_updated_info_error_emits_failed_status() -> None: client = AsyncMock() + del client.submit_prompt async def create_session(title: str | None = None, *, directory: str | None = None) -> str: del title, directory diff --git a/tests/execution/test_cancellation.py b/tests/execution/test_cancellation.py index 9105f99d..00236e91 100644 --- a/tests/execution/test_cancellation.py +++ b/tests/execution/test_cancellation.py @@ -22,6 +22,7 @@ def _memory_session_repository(executor: OpencodeAgentExecutor) -> MemorySession @pytest.mark.asyncio async def test_cancel_interrupts_running_execute_and_keeps_queue_open(caplog): client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt send_started = asyncio.Event() send_cancelled = asyncio.Event() @@ -121,6 +122,7 @@ async def test_cancel_does_not_block_with_real_event_queue() -> None: @pytest.mark.asyncio async def test_cancel_keeps_canceled_status_when_abort_session_fails() -> None: client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt send_started = asyncio.Event() send_cancelled = asyncio.Event() @@ -190,6 +192,7 @@ async def send_message( @pytest.mark.asyncio async def test_cancel_remains_responsive_when_abort_session_hangs(caplog) -> None: client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt send_started = asyncio.Event() send_cancelled = asyncio.Event() @@ -268,6 +271,7 @@ async def slow_abort_session( @pytest.mark.asyncio async def test_cancel_waiting_for_session_lock_does_not_abort_other_generation() -> None: client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt client.create_session.return_value = "session-4" client.send_message = AsyncMock() client.abort_session.return_value = True diff --git a/tests/execution/test_session_ownership.py b/tests/execution/test_session_ownership.py index 236fa246..0dc8380d 100644 --- a/tests/execution/test_session_ownership.py +++ b/tests/execution/test_session_ownership.py @@ -26,6 +26,7 @@ @pytest.fixture def mock_client(): client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt # Define sessions to return sessions = ["session-1", "session-2", "session-3"] current_idx = 0 @@ -156,6 +157,7 @@ async def test_session_hijack_prevention(mock_client): @pytest.mark.asyncio async def test_concurrent_session_create_isolated_by_identity(): client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt created = 0 async def create_session(title=None, directory=None): @@ -284,6 +286,7 @@ def _now() -> float: @pytest.mark.asyncio async def test_preferred_session_claim_is_released_on_upstream_failure(): client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt async def send_message( session_id, @@ -323,6 +326,7 @@ async def send_message( @pytest.mark.asyncio async def test_preferred_session_claim_is_released_on_upstream_cancellation(): client = AsyncMock(spec=OpencodeUpstreamClient) + del client.submit_prompt async def send_message( _session_id, diff --git a/tests/execution/test_streaming_output_contract_core.py b/tests/execution/test_streaming_output_contract_core.py index f15999a0..910136bb 100644 --- a/tests/execution/test_streaming_output_contract_core.py +++ b/tests/execution/test_streaming_output_contract_core.py @@ -21,7 +21,13 @@ from opencode_a2a.execution.stream_state import _StreamOutputState from opencode_a2a.server.context_helpers import normalize_server_call_context from opencode_a2a.task_states import TERMINAL_TASK_STATES -from opencode_a2a.upstream.contracts import PromptReceipt, PromptSubmission, SessionRef +from opencode_a2a.upstream.contracts import ( + ExecutionOutcome, + ExecutionState, + PromptReceipt, + PromptSubmission, + SessionRef, +) from tests.support.helpers import ( DummyEventQueue, make_request_context, @@ -84,6 +90,108 @@ async def test_receipt_only_prompt_submission_fails_without_resending() -> None: ) +@pytest.mark.parametrize( + ("execution_state", "task_state"), + [ + (ExecutionState.FAILED, TaskState.TASK_STATE_FAILED), + (ExecutionState.CANCELLED, TaskState.TASK_STATE_CANCELED), + (ExecutionState.AUTH_REQUIRED, TaskState.TASK_STATE_AUTH_REQUIRED), + ], +) +@pytest.mark.asyncio +async def test_non_completed_outcome_without_error_metadata_is_terminal_before_tools( + monkeypatch, + execution_state: ExecutionState, + task_state: TaskState, +) -> None: + from opencode_a2a.execution import coordinator + + tool_calls: list[dict] = [] + + async def record_tool_call(raw_response: dict, *, a2a_client_manager) -> None: + del a2a_client_manager + tool_calls.append(raw_response) + + monkeypatch.setattr(coordinator, "maybe_handle_tools", record_tool_call) + client = DummyStreamingClient(stream_events_payload=[], response_text="unexpected success") + client.submit_prompt = AsyncMock( + return_value=PromptSubmission( + receipt=PromptReceipt(session=SessionRef("ses-1")), + outcome=ExecutionOutcome( + session=SessionRef("ses-1"), + state=execution_state, + output="unexpected success", + response={ + "parts": [ + { + "type": "tool", + "tool": "a2a_call", + "callID": "call-1", + "state": {"status": "calling"}, + } + ] + }, + ), + ) + ) + executor = OpencodeAgentExecutor(client, streaming_enabled=False) + queue = DummyEventQueue() + + await executor.execute( + make_request_context( + task_id=f"task-outcome-{execution_state.value}", + context_id="ctx-outcome", + text="hello", + ), + queue, + ) + + failed_tasks = [ + event + for event in queue.events + if isinstance(event, Task) and event.status.state == task_state + ] + assert len(failed_tasks) == 1 + assert failed_tasks[0].status.message.parts[0].text == "OpenCode execution did not complete." + assert not any( + isinstance(event, Task) and event.status.state == TaskState.TASK_STATE_COMPLETED + for event in queue.events + ) + assert tool_calls == [] + + +@pytest.mark.asyncio +async def test_invalid_prompt_submission_fails_without_legacy_resend() -> None: + client = DummyStreamingClient(stream_events_payload=[], response_text="unused") + submit_prompt = AsyncMock(return_value=object()) + send_message = AsyncMock() + client.submit_prompt = submit_prompt + client.send_message = send_message + executor = OpencodeAgentExecutor(client, streaming_enabled=False) + queue = DummyEventQueue() + + await executor.execute( + make_request_context( + task_id="task-invalid-submission", + context_id="ctx-invalid-submission", + text="hello", + ), + queue, + ) + + failed_tasks = [ + event + for event in queue.events + if isinstance(event, Task) and event.status.state == TaskState.TASK_STATE_FAILED + ] + assert submit_prompt.await_count == 1 + send_message.assert_not_awaited() + assert len(failed_tasks) == 1 + assert "submit_prompt must return PromptSubmission" in ( + failed_tasks[0].status.message.parts[0].text + ) + + @pytest.mark.asyncio async def test_streaming_accepts_file_input_without_breaking_contract() -> None: client = DummyStreamingClient( From 8d5494a2c279b297fd8394fc23f2c27642651e64 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 12:57:55 +0100 Subject: [PATCH 07/30] fix(compatibility): gate extension capabilities by API version (#6) --- docs/compatibility.md | 39 +++++- docs/extension-specifications.md | 7 + docs/guide.md | 2 +- .../contracts/extensions/__init__.py | 7 +- .../contracts/extensions/capabilities.py | 120 +++++++++++++--- .../contracts/extensions/compatibility.py | 41 +++--- .../contracts/extensions/private_params.py | 44 ++++-- src/opencode_a2a/jsonrpc/application.py | 23 +-- src/opencode_a2a/server/agent_card.py | 42 ++++-- src/opencode_a2a/server/application.py | 47 +++---- src/opencode_a2a/server/middleware.py | 9 +- src/opencode_a2a/server/openapi.py | 119 ++++++++++------ .../test_extension_contract_consistency.py | 96 +++++++++++++ .../test_jsonrpc_unsupported_method.py | 132 +++++++++++++++++- tests/server/test_agent_card.py | 12 +- tests/server/test_app_behaviors.py | 2 + 16 files changed, 591 insertions(+), 151 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index f235787d..2becf290 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -5,16 +5,47 @@ This document defines the compatibility promises `opencode-a2a` currently uphold ## Runtime Support - Python versions: 3.11, 3.12, 3.13, 3.14 -- A2A SDK line: `1.x.y` +- A2A Python SDK: `a2a-sdk==1.2.3` (SDK package version; not an OpenCode API version) - Supported A2A protocol line: `1.0` - OpenCode runtime line: `1.18.x` (verified with `1.18.19`) OpenCode V2 is an opt-in, experimental session-transport slice selected with `OPENCODE_API_VERSION=v2`. It implements `/api/info`, `/api/location`, and session create/get/list transport only; A2A prompt execution and all -OpenCode-specific JSON-RPC extensions remain unavailable. V1 stays the -default, and the adapter does not auto-detect or fall back between versions. -The V2 session transport was exercised against OpenCode `2.0.28`; that +OpenCode-specific JSON-RPC extensions remain unavailable. This remains an +explicit safety gate, not a claim that V2 prompt execution works. + +The V2 selector is **OpenCode's upstream HTTP API version**, not "A2A SDK V2". +The runtime continues to expose A2A protocol 1.0 interfaces. Its V2 Agent Card +contains no extension declarations and retains only an informational execution +status skill. The A2A Python SDK card contract used here requires populated +default input/output mode fields and a skill entry; those required card fields +are not a claim that V2 accepts A2A messages. `SendMessage` enters the SDK task +lifecycle and returns a failed task with the fail-closed explanation before +session creation or prompt submission. `SendStreamingMessage` is rejected as +unsupported because the card declares `streaming=false`. Neither path makes an +upstream HTTP request. Direct +`opencode.*` JSON-RPC calls return `-32601 Method not found` without an upstream +HTTP request. The same extension hiding applies to shared interrupt callbacks. + +| Capability family | OpenCode API V1 (`1.18.x`, verified `1.18.19`) | OpenCode API V2 (`2.0.28` session-transport exercise only) | +| --- | --- | --- | +| A2A protocol interfaces | HTTP+JSON and JSON-RPC, protocol `1.0`; current core behavior | Interfaces remain protocol `1.0`; message send/stream fails before upstream HTTP | +| Shared session/model/stream/interrupt extensions | Declared and implemented as documented | Omitted from cards/OpenAPI; direct calls are unsupported | +| `opencode.sessions.*` (17 catalog methods) | Supported; `opencode.sessions.shell` is deployment-conditional | Omitted and rejected before upstream HTTP | +| Provider/model discovery (2 methods) | Supported | Omitted and rejected before upstream HTTP | +| Project/workspace/worktree (9 methods) | Project discovery stable; workspace/worktree discovery experimental; five mutations deployment-conditional | Omitted and rejected before upstream HTTP | +| Interrupt recovery (2 methods) | Adapter-local and caller-scoped | Omitted and rejected before upstream HTTP | +| `a2a.interrupt.*` callbacks (3 methods) | Supported through the shared interrupt extension | Omitted and rejected before upstream HTTP | +| Internal V2 session transport | Not applicable | `/api/info`, `/api/location`, session create/get/list only; exercised against `2.0.28`, not a model execution test | + +V2 support does **not** claim A2A execution, task-scoped cancellation, +permission/form interventions, request-scoped model overrides, structured tool +execution, or outbound `a2a_call` support. `2.0.28` evidence is limited to +session transport; no V2 model-backed acceptance run is claimed. + +V1 stays the default, and the adapter does not auto-detect or fall back between +versions. The V2 session transport was exercised against OpenCode `2.0.28`; that experimental check does not add V2 to the supported runtime line above. OpenCode V2 allows `external_directory` by default. Before any future release of V2 prompt execution, operators must configure an explicit restrictive upstream diff --git a/docs/extension-specifications.md b/docs/extension-specifications.md index 6ead3b76..64adf70f 100644 --- a/docs/extension-specifications.md +++ b/docs/extension-specifications.md @@ -16,6 +16,13 @@ Provider-private contract note: - Shared `metadata.shared.*` contracts are intended to remain low-risk and transportable. - Compatibility and wire-contract URIs are descriptive metadata contracts, not activatable runtime capabilities. - URI path segments identify the contract, not its auth or disclosure tier; public-vs-extended disclosure is controlled by Agent Card and OpenAPI surfaces. +- These declarations describe the V1 extension contract. With `OPENCODE_API_VERSION=v2`, the public and authenticated Agent Cards and OpenAPI publish no extension declarations or extension method metadata. Direct `opencode.*` and `a2a.interrupt.*` JSON-RPC calls return `-32601 Method not found` before any upstream HTTP request. + +OpenCode API V2 is not A2A SDK V2 and does not enable this extension index. +V2's informational status skill and its non-empty Agent Card default modes are +schema/discovery requirements only; they do not claim A2A message execution, +session methods, model overrides, cancellation, or intervention support. See +[`compatibility.md`](./compatibility.md#runtime-support) for the current matrix. ## SDK and Discovery Compatibility diff --git a/docs/guide.md b/docs/guide.md index 7561bb27..b6c158ae 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -29,7 +29,7 @@ Key variables to understand protocol behavior: - `A2A_STATIC_AUTH_CREDENTIALS`: required static credential registry in JSON array form. Supports multiple `bearer` / `basic` credentials, bearer-only required `principal`, optional `credential_id`, optional `capabilities`, and optional `enabled=false` for explicit disablement. - `OPENCODE_BASE_URL`: upstream OpenCode HTTP endpoint. Default: `http://127.0.0.1:4096`. In two-process deployments, set it explicitly. -- `OPENCODE_API_VERSION`: upstream API selector, `v1` (default) or `v2`. Selection is explicit; the service never detects or falls back to another API version. V2 is experimental session transport only: prompt execution and OpenCode-specific extensions are rejected/hidden until later support lands. +- `OPENCODE_API_VERSION`: OpenCode upstream API selector, `v1` (default) or `v2`; this is unrelated to the `a2a-sdk` package version. Selection is explicit, with no auto-detection or fallback. V2 currently provides internal session transport only, not A2A execution. The A2A 1.0 interfaces remain on the card; the V2 status skill is informational, and SDK-required non-empty default input/output modes do not promise that messages are executable. `SendMessage` returns a failed task before session creation or prompt HTTP; `SendStreamingMessage` is rejected because V2 declares `streaming=false`. Direct extension calls return `-32601 Method not found` before upstream HTTP. OpenCode-specific and shared extension declarations remain hidden. See [compatibility](./compatibility.md#runtime-support) for pinned evidence and limitations. - `OPENCODE_WORKSPACE_ROOT`: service-level default workspace root exposed to OpenCode when clients do not request a narrower directory override. - `A2A_ALLOW_DIRECTORY_OVERRIDE`: controls whether clients may pass `metadata.opencode.directory`. - `A2A_ENABLE_SESSION_SHELL`: gates high-risk JSON-RPC method `opencode.sessions.shell`. diff --git a/src/opencode_a2a/contracts/extensions/__init__.py b/src/opencode_a2a/contracts/extensions/__init__.py index feddae75..cded8241 100644 --- a/src/opencode_a2a/contracts/extensions/__init__.py +++ b/src/opencode_a2a/contracts/extensions/__init__.py @@ -1,7 +1,12 @@ from __future__ import annotations # ruff: noqa: F401 -from .capabilities import JsonRpcCapabilitySnapshot, build_capability_snapshot +from .capabilities import ( + DECLARED_EXTENSION_URIS, + JsonRpcCapabilitySnapshot, + OpenCodeApiVersion, + build_capability_snapshot, +) from .catalog import ( COMMAND_REQUEST_ALLOWED_FIELDS, INTERRUPT_CALLBACK_METHODS, diff --git a/src/opencode_a2a/contracts/extensions/capabilities.py b/src/opencode_a2a/contracts/extensions/capabilities.py index 735d03ee..f906b9d4 100644 --- a/src/opencode_a2a/contracts/extensions/capabilities.py +++ b/src/opencode_a2a/contracts/extensions/capabilities.py @@ -1,8 +1,9 @@ from __future__ import annotations -from collections.abc import Collection +from collections.abc import Collection, Mapping from dataclasses import dataclass -from typing import Any +from types import MappingProxyType +from typing import Any, Literal from ...profile.runtime import ( SESSION_SHELL_TOGGLE, @@ -11,6 +12,29 @@ ) from . import catalog, identifiers +OpenCodeApiVersion = Literal["v1", "v2"] + +DECLARED_EXTENSION_URIS: tuple[str, ...] = ( + identifiers.SESSION_BINDING_EXTENSION_URI, + identifiers.MODEL_SELECTION_EXTENSION_URI, + identifiers.STREAMING_EXTENSION_URI, + identifiers.SESSION_MANAGEMENT_EXTENSION_URI, + identifiers.PROVIDER_DISCOVERY_EXTENSION_URI, + identifiers.WORKSPACE_CONTROL_EXTENSION_URI, + identifiers.INTERRUPT_RECOVERY_EXTENSION_URI, + identifiers.INTERRUPT_CALLBACK_EXTENSION_URI, +) + +_ALL_EXTENSION_METHODS = frozenset( + ( + *catalog.SESSION_METHODS.values(), + *catalog.PROVIDER_DISCOVERY_METHODS.values(), + *catalog.WORKSPACE_CONTROL_METHODS.values(), + *catalog.INTERRUPT_RECOVERY_METHODS.values(), + *catalog.INTERRUPT_CALLBACK_METHODS.values(), + ) +) + @dataclass(frozen=True) class DeploymentConditionalMethod: @@ -46,47 +70,94 @@ def disabled_wire_contract_entry(self) -> dict[str, str] | None: @dataclass(frozen=True) class JsonRpcCapabilitySnapshot: - conditional_methods: dict[str, DeploymentConditionalMethod] + upstream_api_version: OpenCodeApiVersion + conditional_methods: Mapping[str, DeploymentConditionalMethod] + + @property + def extensions_enabled(self) -> bool: + return self.upstream_api_version == "v1" + + @property + def a2a_message_execution_enabled(self) -> bool: + return self.upstream_api_version == "v1" + + @property + def streaming_enabled(self) -> bool: + return self.upstream_api_version == "v1" + + @property + def declared_extension_uris(self) -> tuple[str, ...]: + return DECLARED_EXTENSION_URIS if self.extensions_enabled else () def is_method_enabled(self, method: str) -> bool: + if method in _ALL_EXTENSION_METHODS and not self.extensions_enabled: + return False conditional_method = self.conditional_methods.get(method) if conditional_method is None: return True return conditional_method.enabled def session_management_methods(self) -> dict[str, str]: + if not self.extensions_enabled: + return {} methods = dict(catalog.SESSION_METHODS) if not self.is_method_enabled(catalog.SESSION_METHODS["shell"]): methods.pop("shell", None) return methods + def dispatch_method_bindings(self) -> dict[str, str]: + """Return handler registry bindings; every call is still checked against this policy.""" + methods = self.session_management_methods() + if not self.extensions_enabled: + methods = dict(catalog.SESSION_METHODS) + if not self._deployment_method_enabled(catalog.SESSION_METHODS["shell"]): + methods.pop("shell", None) + methods.update(catalog.PROVIDER_DISCOVERY_METHODS) + methods.update(self.dispatch_workspace_control_methods()) + methods.update(catalog.INTERRUPT_RECOVERY_METHODS) + methods.update(catalog.INTERRUPT_CALLBACK_METHODS) + return methods + + def _deployment_method_enabled(self, method: str) -> bool: + conditional_method = self.conditional_methods.get(method) + return conditional_method is None or conditional_method.enabled + + def dispatch_workspace_control_methods(self) -> dict[str, str]: + methods = dict(catalog.WORKSPACE_DISCOVERY_METHODS) + for key, method in catalog.WORKSPACE_MUTATION_METHODS.items(): + if self._deployment_method_enabled(method): + methods[key] = method + return methods + def session_control_methods(self) -> dict[str, str]: + if not self.extensions_enabled: + return {} methods = dict(catalog.SESSION_CONTROL_METHODS) if not self.is_method_enabled(catalog.SESSION_CONTROL_METHODS["shell"]): methods.pop("shell", None) return methods def workspace_control_methods(self) -> dict[str, str]: - methods = dict(catalog.WORKSPACE_DISCOVERY_METHODS) - for key, method in catalog.WORKSPACE_MUTATION_METHODS.items(): - if self.is_method_enabled(method): - methods[key] = method - return methods + if not self.extensions_enabled: + return {} + return self.dispatch_workspace_control_methods() def supported_jsonrpc_methods(self) -> list[str]: - methods = [ - *catalog.CORE_JSONRPC_METHODS, - *(method for key, method in catalog.SESSION_METHODS.items() if key != "shell"), - *catalog.PROVIDER_DISCOVERY_METHODS.values(), - *self.workspace_control_methods().values(), - *catalog.INTERRUPT_RECOVERY_METHODS.values(), - *catalog.INTERRUPT_CALLBACK_METHODS.values(), - ] + methods = list(catalog.CORE_JSONRPC_METHODS) + if not self.extensions_enabled: + return methods + methods.extend(method for key, method in catalog.SESSION_METHODS.items() if key != "shell") + methods.extend(catalog.PROVIDER_DISCOVERY_METHODS.values()) + methods.extend(self.workspace_control_methods().values()) + methods.extend(catalog.INTERRUPT_RECOVERY_METHODS.values()) + methods.extend(catalog.INTERRUPT_CALLBACK_METHODS.values()) if self.is_method_enabled(catalog.SESSION_CONTROL_METHODS["shell"]): methods.append(catalog.SESSION_CONTROL_METHODS["shell"]) return methods def extension_jsonrpc_methods(self) -> list[str]: + if not self.extensions_enabled: + return [] methods = [ *(method for key, method in catalog.SESSION_METHODS.items() if key != "shell"), *catalog.PROVIDER_DISCOVERY_METHODS.values(), @@ -99,6 +170,8 @@ def extension_jsonrpc_methods(self) -> list[str]: return methods def conditionally_available_methods(self) -> dict[str, dict[str, str]]: + if not self.extensions_enabled: + return {} return { method: disabled_entry for method, conditional_method in self.conditional_methods.items() @@ -106,6 +179,8 @@ def conditionally_available_methods(self) -> dict[str, dict[str, str]]: } def method_flags(self, methods: Collection[str]) -> dict[str, dict[str, Any]]: + if not self.extensions_enabled: + return {} return { method: conditional_method.control_method_flag() for method, conditional_method in self.conditional_methods.items() @@ -113,13 +188,19 @@ def method_flags(self, methods: Collection[str]) -> dict[str, dict[str, Any]]: } def conditional_method_retention(self) -> dict[str, dict[str, Any]]: + if not self.extensions_enabled: + return {} return { method: conditional_method.method_retention() for method, conditional_method in self.conditional_methods.items() } -def build_capability_snapshot(*, runtime_profile: RuntimeProfile) -> JsonRpcCapabilitySnapshot: +def build_capability_snapshot( + *, + runtime_profile: RuntimeProfile, + upstream_api_version: OpenCodeApiVersion = "v1", +) -> JsonRpcCapabilitySnapshot: conditional_methods = { catalog.SESSION_CONTROL_METHODS["shell"]: DeploymentConditionalMethod( method=catalog.SESSION_CONTROL_METHODS["shell"], @@ -139,4 +220,7 @@ def build_capability_snapshot(*, runtime_profile: RuntimeProfile) -> JsonRpcCapa for method in catalog.WORKSPACE_MUTATION_METHODS.values() } ) - return JsonRpcCapabilitySnapshot(conditional_methods=conditional_methods) + return JsonRpcCapabilitySnapshot( + upstream_api_version=upstream_api_version, + conditional_methods=MappingProxyType(conditional_methods), + ) diff --git a/src/opencode_a2a/contracts/extensions/compatibility.py b/src/opencode_a2a/contracts/extensions/compatibility.py index 328b751c..5a45ba9f 100644 --- a/src/opencode_a2a/contracts/extensions/compatibility.py +++ b/src/opencode_a2a/contracts/extensions/compatibility.py @@ -5,17 +5,10 @@ from ...profile.runtime import RuntimeProfile from ...protocol_versions import A2A_PROTOCOL_VERSION, normalize_protocol_version from . import catalog, identifiers -from .capabilities import JsonRpcCapabilitySnapshot, build_capability_snapshot - -DECLARED_EXTENSION_URIS: tuple[str, ...] = ( - identifiers.SESSION_BINDING_EXTENSION_URI, - identifiers.MODEL_SELECTION_EXTENSION_URI, - identifiers.STREAMING_EXTENSION_URI, - identifiers.SESSION_MANAGEMENT_EXTENSION_URI, - identifiers.PROVIDER_DISCOVERY_EXTENSION_URI, - identifiers.WORKSPACE_CONTROL_EXTENSION_URI, - identifiers.INTERRUPT_RECOVERY_EXTENSION_URI, - identifiers.INTERRUPT_CALLBACK_EXTENSION_URI, +from .capabilities import ( + JsonRpcCapabilitySnapshot, + OpenCodeApiVersion, + build_capability_snapshot, ) @@ -115,7 +108,11 @@ def _build_method_retention_records( } -def _build_extension_retention() -> dict[str, dict[str, Any]]: +def _build_extension_retention( + capability_snapshot: JsonRpcCapabilitySnapshot, +) -> dict[str, dict[str, Any]]: + if not capability_snapshot.extensions_enabled: + return {} return { identifiers.SESSION_BINDING_EXTENSION_URI: _build_retention_record( surface="core-runtime-metadata", @@ -163,6 +160,8 @@ def _build_method_retention( surface="core", retention="required", ) + if not capability_snapshot.extensions_enabled: + return method_retention method_retention.update( _build_method_retention_records( [method for key, method in catalog.SESSION_METHODS.items() if key != "shell"], @@ -224,6 +223,8 @@ def build_compatibility_profile_params( runtime_profile: RuntimeProfile, supported_protocol_versions: tuple[str, ...] | list[str] | None = None, default_protocol_version: str | None = None, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, + upstream_api_version: OpenCodeApiVersion = "v1", ) -> dict[str, Any]: ( declared_protocol_version, @@ -238,7 +239,10 @@ def build_compatibility_profile_params( supported_protocol_versions=declared_supported_protocol_versions, default_protocol_version=declared_default_protocol_version, ) - capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile, + upstream_api_version=upstream_api_version, + ) service_behaviors = build_service_behavior_contract_params() return { **runtime_profile.summary_dict(protocol_version=declared_protocol_version), @@ -246,7 +250,7 @@ def build_compatibility_profile_params( "supported_protocol_versions": declared_supported_protocol_versions, "protocol_compatibility": protocol_compatibility, "core": _build_core_contract_surface(), - "extension_retention": _build_extension_retention(), + "extension_retention": _build_extension_retention(capability_snapshot), "method_retention": _build_method_retention(capability_snapshot), "service_behaviors": service_behaviors, "consumer_guidance": [ @@ -350,6 +354,8 @@ def build_wire_contract_params( runtime_profile: RuntimeProfile, supported_protocol_versions: tuple[str, ...] | list[str] | None = None, default_protocol_version: str | None = None, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, + upstream_api_version: OpenCodeApiVersion = "v1", ) -> dict[str, Any]: ( declared_protocol_version, @@ -364,7 +370,10 @@ def build_wire_contract_params( supported_protocol_versions=declared_supported_protocol_versions, default_protocol_version=declared_default_protocol_version, ) - capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile, + upstream_api_version=upstream_api_version, + ) service_behaviors = build_service_behavior_contract_params() return { @@ -381,7 +390,7 @@ def build_wire_contract_params( "conditionally_available_methods": ( capability_snapshot.conditionally_available_methods() ), - "extension_uris": list(DECLARED_EXTENSION_URIS), + "extension_uris": list(capability_snapshot.declared_extension_uris), }, "all_jsonrpc_methods": capability_snapshot.supported_jsonrpc_methods(), "service_behaviors": service_behaviors, diff --git a/src/opencode_a2a/contracts/extensions/private_params.py b/src/opencode_a2a/contracts/extensions/private_params.py index 0b4ef66a..04c3bc73 100644 --- a/src/opencode_a2a/contracts/extensions/private_params.py +++ b/src/opencode_a2a/contracts/extensions/private_params.py @@ -4,7 +4,7 @@ from ...profile.runtime import RuntimeProfile from . import catalog, identifiers, public_params -from .capabilities import build_capability_snapshot +from .capabilities import JsonRpcCapabilitySnapshot, build_capability_snapshot from .contract_docs import build_method_contract_docs @@ -19,11 +19,18 @@ def build_session_management_extension_params( *, runtime_profile: RuntimeProfile, context_id_prefix: str, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> dict[str, Any]: - capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile + ) methods = capability_snapshot.session_management_methods() - read_methods = dict(catalog.SESSION_READ_METHODS) - mutation_methods = dict(catalog.SESSION_MUTATION_METHODS) + read_methods = ( + dict(catalog.SESSION_READ_METHODS) if capability_snapshot.extensions_enabled else {} + ) + mutation_methods = ( + dict(catalog.SESSION_MUTATION_METHODS) if capability_snapshot.extensions_enabled else {} + ) control_methods = capability_snapshot.session_control_methods() active_session_methods = set(methods.values()) pagination_applies_to: list[str] = [] @@ -77,11 +84,19 @@ def build_session_management_extension_params( def build_interrupt_recovery_extension_params( *, runtime_profile: RuntimeProfile, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> dict[str, Any]: + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile + ) + methods = ( + dict(catalog.INTERRUPT_RECOVERY_METHODS) if capability_snapshot.extensions_enabled else {} + ) return { - "methods": dict(catalog.INTERRUPT_RECOVERY_METHODS), + "methods": methods, "method_contracts": build_method_contract_docs( - catalog.INTERRUPT_RECOVERY_METHOD_CONTRACTS.values() + catalog.INTERRUPT_RECOVERY_METHOD_CONTRACTS.values(), + active_methods=set(methods.values()), ), "supported_metadata": [], "provider_private_metadata": [], @@ -129,11 +144,19 @@ def build_interrupt_recovery_extension_params( def build_provider_discovery_extension_params( *, runtime_profile: RuntimeProfile, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> dict[str, Any]: + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile + ) + methods = ( + dict(catalog.PROVIDER_DISCOVERY_METHODS) if capability_snapshot.extensions_enabled else {} + ) return { - "methods": dict(catalog.PROVIDER_DISCOVERY_METHODS), + "methods": methods, "method_contracts": build_method_contract_docs( - catalog.PROVIDER_DISCOVERY_METHOD_CONTRACTS.values() + catalog.PROVIDER_DISCOVERY_METHOD_CONTRACTS.values(), + active_methods=set(methods.values()), ), "supported_metadata": ["opencode.directory", "opencode.workspace.id"], "provider_private_metadata": ["opencode.directory", "opencode.workspace.id"], @@ -190,8 +213,11 @@ def build_provider_discovery_extension_params( def build_workspace_control_extension_params( *, runtime_profile: RuntimeProfile, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> dict[str, Any]: - capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile + ) methods = capability_snapshot.workspace_control_methods() active_workspace_methods = set(methods.values()) diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index 701188f2..94776643 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -24,7 +24,7 @@ from starlette.requests import Request from starlette.responses import Response -from ..contracts.extensions import ALL_EXTENSION_URIS +from ..contracts.extensions import ALL_EXTENSION_URIS, JsonRpcCapabilitySnapshot from ..extension_negotiation import ( requested_extensions_from_call_context, ) @@ -63,8 +63,7 @@ def __init__( http_handler, upstream_client: OpencodeUpstreamClient, methods: dict[str, str], - supported_methods: list[str], - extensions_enabled: bool = True, + capability_snapshot: JsonRpcCapabilitySnapshot, directory_resolver: Callable[[str | None], str | None] | None = None, session_claim: Callable[..., Awaitable[bool]] | None = None, session_claim_finalize: Callable[..., Awaitable[None]] | None = None, @@ -77,7 +76,7 @@ def __init__( super().__init__(request_handler=http_handler, **kwargs) self._http_handler = http_handler self._upstream_client = upstream_client - self._extensions_enabled = extensions_enabled + self._capability_snapshot = capability_snapshot self._method_session_status = methods["status"] self._method_list_sessions = methods["list_sessions"] self._method_get_session = methods["get_session"] @@ -111,7 +110,7 @@ def __init__( self._method_reply_permission = methods["reply_permission"] self._method_reply_question = methods["reply_question"] self._method_reject_question = methods["reject_question"] - self._supported_methods = list(supported_methods) + self._supported_methods = capability_snapshot.supported_jsonrpc_methods() missing_control_hooks = [ name for name, hook in ( @@ -352,7 +351,9 @@ async def _handle_core_request( self._context_builder.build(request) ) request.state.activated_extensions = tuple( - value for value in requested_extensions if value in ALL_EXTENSION_URIS + value + for value in requested_extensions + if self._capability_snapshot.extensions_enabled and value in ALL_EXTENSION_URIS ) return JSONResponse( { @@ -423,11 +424,11 @@ async def handle_requests(self, request: Request) -> Response: except Exception: return await super().handle_requests(request) - extension_spec = ( - self._extension_method_registry.resolve(base_request.method) - if self._extensions_enabled - else None - ) + extension_spec = self._extension_method_registry.resolve(base_request.method) + if extension_spec is not None and not self._capability_snapshot.is_method_enabled( + base_request.method + ): + extension_spec = None if extension_spec is None: return await self._handle_core_request( request, diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index e6cf3eaf..6c51753b 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -63,13 +63,16 @@ def _build_agent_card_description( settings: Settings, runtime_profile: RuntimeProfile, *, + capability_snapshot: JsonRpcCapabilitySnapshot, include_detailed_contracts: bool, ) -> str: base = (settings.a2a_description or "").strip() or "OpenCode A2A runtime." - if settings.opencode_api_version == "v2": + if not capability_snapshot.a2a_message_execution_enabled: return ( f"{base} Only internal experimental OpenCode V2 session transport exists; " - "A2A message execution and OpenCode-specific extensions are unsupported." + "A2A SendMessage and SendStreamingMessage fail fast before any upstream request, " + "and OpenCode-specific extensions are unsupported. Agent Card default input/output " + "modes are required SDK schema fields, not a claim that message execution works." ) if not include_detailed_contracts: public_parts: list[str] = [ @@ -177,8 +180,11 @@ def _build_workspace_control_skill_examples(*, capability_snapshot) -> list[str] def _build_agent_extensions( *, runtime_profile: RuntimeProfile, + capability_snapshot: JsonRpcCapabilitySnapshot, include_detailed_contracts: bool, ) -> list[AgentExtension]: + if not capability_snapshot.declared_extension_uris: + return [] session_binding_extension_params = build_session_binding_extension_params( runtime_profile=runtime_profile, ) @@ -189,15 +195,19 @@ def _build_agent_extensions( session_management_extension_params = build_session_management_extension_params( runtime_profile=runtime_profile, context_id_prefix=SESSION_CONTEXT_PREFIX, + capability_snapshot=capability_snapshot, ) provider_discovery_extension_params = build_provider_discovery_extension_params( runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, ) workspace_control_extension_params = build_workspace_control_extension_params( runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, ) interrupt_recovery_extension_params = build_interrupt_recovery_extension_params( runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, ) interrupt_callback_extension_params = build_interrupt_callback_extension_params( runtime_profile=runtime_profile, @@ -205,10 +215,12 @@ def _build_agent_extensions( compatibility_profile_params = build_compatibility_profile_params( protocol_version=A2A_PROTOCOL_VERSION, runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, ) wire_contract_params = build_wire_contract_params( protocol_version=A2A_PROTOCOL_VERSION, runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, ) public_extensions = [ @@ -361,14 +373,15 @@ def _build_agent_skills( capability_snapshot: JsonRpcCapabilitySnapshot, include_detailed_contracts: bool, ) -> list[AgentSkill]: - if settings.opencode_api_version == "v2": + if not capability_snapshot.a2a_message_execution_enabled: return [ AgentSkill( id="a2a-execution-unavailable", name="A2A Execution Status", description=( "Informational only: internal experimental OpenCode V2 session transport " - "exists, but no A2A message or session operation is available." + "exists, but no A2A message or session operation is available. " + "SendMessage and SendStreamingMessage fail before any upstream request." ), tags=["status"], ) @@ -497,9 +510,14 @@ def build_agent_card( settings: Settings, *, include_detailed_contracts: bool = False, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> AgentCard: public_url = settings.a2a_public_url.rstrip("/") runtime_profile = build_runtime_profile(settings) + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile, + upstream_api_version=settings.opencode_api_version, + ) security_schemes: dict[str, SecurityScheme] = {} security_requirements: list[SecurityRequirement] = [] configured_auth_schemes = { @@ -522,13 +540,12 @@ def build_agent_card( ) ) security_requirements.append(SecurityRequirement(schemes=cast(Any, {"basicAuth": {}}))) - capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) - return AgentCard( name=settings.a2a_title, description=_build_agent_card_description( settings, runtime_profile, + capability_snapshot=capability_snapshot, include_detailed_contracts=include_detailed_contracts, ), documentation_url=settings.a2a_documentation_url, @@ -548,15 +565,12 @@ def build_agent_card( default_input_modes=list(_CHAT_INPUT_MODES), default_output_modes=list(_CHAT_OUTPUT_MODES), capabilities=AgentCapabilities( - streaming=settings.opencode_api_version == "v1", + streaming=capability_snapshot.streaming_enabled, extended_agent_card=True, - extensions=( - [] - if settings.opencode_api_version == "v2" - else _build_agent_extensions( - runtime_profile=runtime_profile, - include_detailed_contracts=include_detailed_contracts, - ) + extensions=_build_agent_extensions( + runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, + include_detailed_contracts=include_detailed_contracts, ), ), skills=_build_agent_skills( diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index 9b5fc4e2..f65df15f 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -20,16 +20,10 @@ from ..a2a_protocol import ( AGENT_CARD_WELL_KNOWN_PATH, - CORE_JSONRPC_METHODS, EXTENDED_AGENT_CARD_PATH, ) from ..config import Settings -from ..contracts.extensions import ( - INTERRUPT_CALLBACK_METHODS, - INTERRUPT_RECOVERY_METHODS, - PROVIDER_DISCOVERY_METHODS, - build_capability_snapshot, -) +from ..contracts.extensions import build_capability_snapshot from ..execution.executor import OpencodeAgentExecutor from ..execution.metrics import render_prometheus_metrics from ..jsonrpc.application import ( @@ -182,8 +176,17 @@ def create_app(settings: Settings) -> FastAPI: interrupt_request_repository=runtime_state_runtime.interrupt_request_repository, ) client_manager = A2AClientManager(settings) - agent_card = build_agent_card(settings) - extended_agent_card = build_agent_card(settings, include_detailed_contracts=True) + runtime_profile = build_runtime_profile(settings) + capability_snapshot = build_capability_snapshot( + runtime_profile=runtime_profile, + upstream_api_version=settings.opencode_api_version, + ) + agent_card = build_agent_card(settings, capability_snapshot=capability_snapshot) + extended_agent_card = build_agent_card( + settings, + include_detailed_contracts=True, + capability_snapshot=capability_snapshot, + ) executor = OpencodeAgentExecutor( upstream_client, streaming_enabled=True, @@ -207,28 +210,14 @@ def create_app(settings: Settings) -> FastAPI: ) context_builder = IdentityAwareCallContextBuilder() - runtime_profile = build_runtime_profile(settings) - capability_snapshot = build_capability_snapshot(runtime_profile=runtime_profile) - - jsonrpc_methods = { - **capability_snapshot.session_management_methods(), - **dict(PROVIDER_DISCOVERY_METHODS), - **capability_snapshot.workspace_control_methods(), - **dict(INTERRUPT_RECOVERY_METHODS), - **dict(INTERRUPT_CALLBACK_METHODS), - } + jsonrpc_methods = capability_snapshot.dispatch_method_bindings() # Build JSON-RPC app (POST / by default) and attach REST endpoints (HTTP+JSON) to the same app. jsonrpc_app = OpencodeSessionManagementJSONRPCApplication( http_handler=handler, context_builder=context_builder, upstream_client=upstream_client, - supported_methods=( - list(CORE_JSONRPC_METHODS) - if settings.opencode_api_version == "v2" - else capability_snapshot.supported_jsonrpc_methods() - ), - extensions_enabled=settings.opencode_api_version == "v1", + capability_snapshot=capability_snapshot, directory_resolver=( partial( executor._sandbox_policy.resolve_directory, @@ -359,12 +348,18 @@ async def authenticated_extended_agent_card_route() -> JSONResponse: app.state.agent_executor = executor app.state.upstream_client = upstream_client app.state.a2a_client_manager = client_manager - _patch_jsonrpc_openapi_contract(app, settings, runtime_profile=runtime_profile) + _patch_jsonrpc_openapi_contract( + app, + settings, + runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, + ) install_runtime_middlewares( app, settings, public_card_etag=public_card_etag, extended_card_etag=extended_card_etag, + capability_snapshot=capability_snapshot, ) @app.get("/health") diff --git a/src/opencode_a2a/server/middleware.py b/src/opencode_a2a/server/middleware.py index fe61246b..e22fb231 100644 --- a/src/opencode_a2a/server/middleware.py +++ b/src/opencode_a2a/server/middleware.py @@ -22,7 +22,11 @@ build_static_auth_credentials, ) from ..client.network_policy import matches_allowed_host -from ..contracts.extensions import ALL_EXTENSION_URIS, PUBLIC_EXTENSION_URIS +from ..contracts.extensions import ( + ALL_EXTENSION_URIS, + PUBLIC_EXTENSION_URIS, + JsonRpcCapabilitySnapshot, +) from ..execution.metrics import emit_metric from ..jsonrpc.error_responses import ( adapt_jsonrpc_error, @@ -330,6 +334,7 @@ def install_runtime_middlewares( *, public_card_etag: str, extended_card_etag: str, + capability_snapshot: JsonRpcCapabilitySnapshot, ) -> None: def _requires_protocol_negotiation(request: Request) -> bool: if request.url.path == "/" and request.method == "POST": @@ -495,7 +500,7 @@ def _resolve_activated_extensions(request: Request) -> tuple[str, ...]: path = request.url.path supported_extensions: tuple[str, ...] - if settings.opencode_api_version == "v2": + if not capability_snapshot.extensions_enabled: supported_extensions = () elif path == AGENT_CARD_WELL_KNOWN_PATH: supported_extensions = PUBLIC_EXTENSION_URIS diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index daf5b3e0..bc9a27b2 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -12,6 +12,8 @@ MODEL_SELECTION_EXTENSION_URI, SESSION_BINDING_EXTENSION_URI, STREAMING_EXTENSION_URI, + JsonRpcCapabilitySnapshot, + build_capability_snapshot, build_interrupt_callback_extension_params, build_model_selection_extension_params, build_public_interrupt_callback_extension_params, @@ -25,7 +27,16 @@ from ..profile.runtime import RuntimeProfile -def _build_jsonrpc_extension_openapi_description() -> str: +def _build_jsonrpc_extension_openapi_description( + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, +) -> str: + if capability_snapshot is not None and not capability_snapshot.extensions_enabled: + return ( + "A2A JSON-RPC retains the A2A protocol 1.0 method surface. With OpenCode API V2, " + "SendMessage and SendStreamingMessage fail before any upstream request; direct " + "opencode.* extension calls return JSON-RPC -32601 Method not found. No OpenCode " + "extension contracts are advertised." + ) interrupt_methods = ", ".join(sorted(INTERRUPT_CALLBACK_METHODS.values())) core_methods = ", ".join(CORE_JSONRPC_METHODS) return ( @@ -243,7 +254,12 @@ def _patch_jsonrpc_openapi_contract( settings: Settings, *, runtime_profile: RuntimeProfile, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> None: + capability_snapshot = capability_snapshot or build_capability_snapshot( + runtime_profile=runtime_profile, + upstream_api_version=settings.opencode_api_version, + ) session_binding = build_session_binding_extension_params( runtime_profile=runtime_profile, ) @@ -275,51 +291,50 @@ def custom_openapi() -> dict[str, Any]: post = root_path.get("post") if isinstance(post, dict): post["summary"] = "Handle A2A JSON-RPC Requests" - post["description"] = _build_jsonrpc_extension_openapi_description() - post["x-a2a-extension-contracts"] = { - "session_binding": { - "extension_uri": SESSION_BINDING_EXTENSION_URI, - **build_public_session_binding_extension_params(session_binding), - }, - "model_selection": { - "extension_uri": MODEL_SELECTION_EXTENSION_URI, - **select_public_extension_params( - model_selection, - keys=( - "metadata_field", - "behavior", - "applies_to_methods", - "supported_metadata", - "provider_private_metadata", - "fields", - ), - ), - }, - "streaming": { - "extension_uri": STREAMING_EXTENSION_URI, - **build_public_streaming_extension_params(streaming), - }, - "interrupt_callback": { - "extension_uri": INTERRUPT_CALLBACK_EXTENSION_URI, - **select_public_extension_params( - build_public_interrupt_callback_extension_params( - interrupt_callback + post["description"] = _build_jsonrpc_extension_openapi_description( + capability_snapshot + ) + if capability_snapshot.extensions_enabled: + post["x-a2a-extension-contracts"] = { + "session_binding": { + "extension_uri": SESSION_BINDING_EXTENSION_URI, + **build_public_session_binding_extension_params(session_binding), + }, + "model_selection": { + "extension_uri": MODEL_SELECTION_EXTENSION_URI, + **select_public_extension_params( + model_selection, + keys=( + "metadata_field", + "behavior", + "applies_to_methods", + "supported_metadata", + "provider_private_metadata", + "fields", + ), ), - keys=( - "methods", - "supported_interrupt_events", - "request_id_field", - "interrupt_metadata_field", - "interrupt_fields", + }, + "streaming": { + "extension_uri": STREAMING_EXTENSION_URI, + **build_public_streaming_extension_params(streaming), + }, + "interrupt_callback": { + "extension_uri": INTERRUPT_CALLBACK_EXTENSION_URI, + **select_public_extension_params( + build_public_interrupt_callback_extension_params( + interrupt_callback + ), + keys=( + "methods", + "supported_interrupt_events", + "request_id_field", + "interrupt_metadata_field", + "interrupt_fields", + ), ), - ), - }, - } - if settings.opencode_api_version == "v2": - post["description"] = ( - "OpenCode V2 session transport is experimental. A2A prompt " - "execution and extension methods are not implemented." - ) + }, + } + else: post.pop("x-a2a-extension-contracts", None) request_body = post.setdefault("requestBody", {}) @@ -330,7 +345,10 @@ def custom_openapi() -> dict[str, Any]: app_json = content.setdefault("application/json", {}) if isinstance(app_json, dict): app_json["schema"] = {"$ref": "#/components/schemas/A2ARequest"} - app_json["examples"] = _build_jsonrpc_extension_openapi_examples() + if capability_snapshot.extensions_enabled: + app_json["examples"] = ( + _build_jsonrpc_extension_openapi_examples() + ) rest_post_contracts: dict[str, dict[str, Any]] = { "/message:send": { @@ -352,6 +370,14 @@ def custom_openapi() -> dict[str, Any]: "schema_ref": "#/components/schemas/SendStreamingMessageRequest", }, } + if not capability_snapshot.a2a_message_execution_enabled: + v2_message_description = ( + "OpenCode API V2 session transport is experimental. This A2A message " + "endpoint fails before any upstream request; message execution is not " + "implemented." + ) + for contract in rest_post_contracts.values(): + contract["description"] = v2_message_description rest_examples = _build_rest_message_openapi_examples() for rest_path, contract in rest_post_contracts.items(): rest_path_item = paths.get(rest_path) @@ -374,7 +400,8 @@ def custom_openapi() -> dict[str, Any]: if not isinstance(app_json, dict): continue app_json["schema"] = {"$ref": contract["schema_ref"]} - app_json["examples"] = rest_examples + if capability_snapshot.a2a_message_execution_enabled: + app_json["examples"] = rest_examples app.openapi_schema = schema return schema diff --git a/tests/contracts/test_extension_contract_consistency.py b/tests/contracts/test_extension_contract_consistency.py index a2ed109c..e2761726 100644 --- a/tests/contracts/test_extension_contract_consistency.py +++ b/tests/contracts/test_extension_contract_consistency.py @@ -37,6 +37,7 @@ build_streaming_extension_params, build_wire_contract_params, build_workspace_control_extension_params, + catalog, select_public_extension_params, ) from opencode_a2a.jsonrpc.methods import SESSION_CONTEXT_PREFIX @@ -408,6 +409,101 @@ async def test_runtime_supported_methods_align_with_capability_snapshot( assert error["data"]["supportedMethods"] == wire_contract["all_jsonrpc_methods"] +def test_v1_capability_snapshot_preserves_published_method_order_and_is_immutable() -> None: + runtime_profile = build_runtime_profile(make_settings(test_bearer_token="test-token")) + snapshot = build_capability_snapshot(runtime_profile=runtime_profile) + + assert snapshot.upstream_api_version == "v1" + assert snapshot.supported_jsonrpc_methods() == [ + "SendMessage", + "SendStreamingMessage", + "GetTask", + "ListTasks", + "CancelTask", + "CreateTaskPushNotificationConfig", + "GetTaskPushNotificationConfig", + "ListTaskPushNotificationConfigs", + "DeleteTaskPushNotificationConfig", + "SubscribeToTask", + "GetExtendedAgentCard", + "opencode.sessions.status", + "opencode.sessions.list", + "opencode.sessions.messages.list", + "opencode.sessions.get", + "opencode.sessions.children", + "opencode.sessions.todo", + "opencode.sessions.diff", + "opencode.sessions.messages.get", + "opencode.sessions.prompt_async", + "opencode.sessions.command", + "opencode.sessions.fork", + "opencode.sessions.share", + "opencode.sessions.unshare", + "opencode.sessions.summarize", + "opencode.sessions.revert", + "opencode.sessions.unrevert", + "opencode.providers.list", + "opencode.models.list", + "opencode.projects.list", + "opencode.projects.current", + "opencode.workspaces.list", + "opencode.worktrees.list", + "opencode.permissions.list", + "opencode.questions.list", + "a2a.interrupt.permission.reply", + "a2a.interrupt.question.reply", + "a2a.interrupt.question.reject", + ] + with pytest.raises(TypeError): + snapshot.conditional_methods["opencode.sessions.shell"] = object() + + +def test_v2_capability_snapshot_disables_every_catalogued_extension() -> None: + runtime_profile = build_runtime_profile(make_settings(test_bearer_token="test-token")) + snapshot = build_capability_snapshot( + runtime_profile=runtime_profile, + upstream_api_version="v2", + ) + extension_methods = { + *catalog.SESSION_METHODS.values(), + *catalog.PROVIDER_DISCOVERY_METHODS.values(), + *catalog.WORKSPACE_CONTROL_METHODS.values(), + *catalog.INTERRUPT_RECOVERY_METHODS.values(), + *catalog.INTERRUPT_CALLBACK_METHODS.values(), + } + + assert snapshot.supported_jsonrpc_methods() == list(catalog.CORE_JSONRPC_METHODS) + assert snapshot.extension_jsonrpc_methods() == [] + assert snapshot.declared_extension_uris == () + assert set(snapshot.dispatch_method_bindings().values()) == extension_methods - set( + snapshot.conditional_methods + ) + assert set(snapshot.conditional_methods) == { + catalog.SESSION_CONTROL_METHODS["shell"], + *catalog.WORKSPACE_MUTATION_METHODS.values(), + } + assert all(not binding.enabled for binding in snapshot.conditional_methods.values()) + wire_contract = build_wire_contract_params( + protocol_version=A2A_PROTOCOL_VERSION, + runtime_profile=runtime_profile, + capability_snapshot=snapshot, + ) + compatibility_profile = build_compatibility_profile_params( + protocol_version=A2A_PROTOCOL_VERSION, + runtime_profile=runtime_profile, + capability_snapshot=snapshot, + ) + assert wire_contract["extensions"] == { + "jsonrpc_methods": [], + "conditionally_available_methods": {}, + "extension_uris": [], + } + assert wire_contract["all_jsonrpc_methods"] == list(catalog.CORE_JSONRPC_METHODS) + assert compatibility_profile["extension_retention"] == {} + assert set(compatibility_profile["method_retention"]) == set(catalog.CORE_JSONRPC_METHODS) + assert all(not snapshot.is_method_enabled(method) for method in extension_methods) + + @pytest.mark.asyncio @pytest.mark.parametrize( ("method", "params", "interrupt_type"), diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index 75850183..467e1a5c 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -1,12 +1,20 @@ import httpx import pytest -from opencode_a2a.contracts.extensions import SESSION_BINDING_EXTENSION_URI +from opencode_a2a.contracts.extensions import SESSION_BINDING_EXTENSION_URI, catalog from opencode_a2a.protocol_versions import A2A_PROTOCOL_VERSION from opencode_a2a.server.application import create_app from opencode_a2a.upstream.v2 import V2UpstreamAdapter from tests.support.settings import make_settings +_V2_EXTENSION_METHODS = ( + *catalog.SESSION_METHODS.values(), + *catalog.PROVIDER_DISCOVERY_METHODS.values(), + *catalog.WORKSPACE_CONTROL_METHODS.values(), + *catalog.INTERRUPT_RECOVERY_METHODS.values(), + *catalog.INTERRUPT_CALLBACK_METHODS.values(), +) + @pytest.mark.asyncio async def test_unsupported_method_returns_unified_error() -> None: @@ -43,10 +51,34 @@ async def test_v2_rejects_unimplemented_opencode_extension_methods() -> None: app = create_app(make_settings(opencode_api_version="v2")) assert isinstance(app.state.upstream_client, V2UpstreamAdapter) openapi = app.openapi() - assert "x-a2a-extension-contracts" not in openapi["paths"]["/"]["post"] + jsonrpc_contract = openapi["paths"]["/"]["post"] + assert "x-a2a-extension-contracts" not in jsonrpc_contract + assert "fail before any upstream request" in jsonrpc_contract["description"] + assert "examples" not in jsonrpc_contract["requestBody"]["content"]["application/json"] + for path in ("/message:send", "/message:stream"): + rest_contract = openapi["paths"][path]["post"] + assert "fails before any upstream request" in rest_contract["description"] + assert "examples" not in rest_contract["requestBody"]["content"]["application/json"] transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + extended_card = await client.get( + "/extendedAgentCard", + headers={"Authorization": "Bearer test-token"}, + ) + rpc_extended_card = await client.post( + "/", + headers={ + "Authorization": "Bearer test-token", + "A2A-Extensions": SESSION_BINDING_EXTENSION_URI, + }, + json={ + "jsonrpc": "2.0", + "id": 122, + "method": "GetExtendedAgentCard", + "params": {}, + }, + ) response = await client.post( "/", headers={ @@ -61,6 +93,14 @@ async def test_v2_rejects_unimplemented_opencode_extension_methods() -> None: }, ) + assert extended_card.status_code == 200 + assert not extended_card.json()["capabilities"].get("extensions") + assert [skill["id"] for skill in extended_card.json()["skills"]] == [ + "a2a-execution-unavailable" + ] + assert rpc_extended_card.status_code == 200 + assert "A2A-Extensions" not in rpc_extended_card.headers + assert not rpc_extended_card.json()["result"]["capabilities"].get("extensions") assert response.status_code == 200 body = response.json() assert body["error"]["code"] == -32601 @@ -70,6 +110,94 @@ async def test_v2_rejects_unimplemented_opencode_extension_methods() -> None: assert "A2A-Extensions" not in response.headers +@pytest.mark.asyncio +@pytest.mark.parametrize("method", _V2_EXTENSION_METHODS) +async def test_v2_rejects_every_catalogued_extension_before_upstream_http( + method: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = create_app(make_settings(opencode_api_version="v2")) + upstream_calls: list[tuple[str, str]] = [] + + async def record_upstream_call(path: str, **_kwargs) -> None: + upstream_calls.append((method, path)) + raise AssertionError("V2 extension dispatch must not make an upstream HTTP request") + + monkeypatch.setattr(app.state.upstream_client, "_get_json", record_upstream_call) + monkeypatch.setattr(app.state.upstream_client, "_post_json", record_upstream_call) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/", + headers={ + "Authorization": "Bearer test-token", + "A2A-Extensions": SESSION_BINDING_EXTENSION_URI, + }, + json={"jsonrpc": "2.0", "id": 123, "method": method, "params": {}}, + ) + + assert response.status_code == 200 + assert response.json()["error"]["code"] == -32601 + assert response.json()["error"]["message"] == "Method not found" + assert method not in response.json()["error"]["data"]["supportedMethods"] + assert response.json()["error"]["data"]["supportedMethods"] == list( + catalog.CORE_JSONRPC_METHODS + ) + assert upstream_calls == [] + + +@pytest.mark.asyncio +async def test_v2_core_message_paths_fail_before_upstream_http( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = create_app(make_settings(opencode_api_version="v2")) + upstream_calls: list[tuple[str, str]] = [] + + async def record_upstream_call(path: str, **_kwargs) -> None: + upstream_calls.append(("message", path)) + raise AssertionError("V2 message execution must not make an upstream HTTP request") + + monkeypatch.setattr(app.state.upstream_client, "_get_json", record_upstream_call) + monkeypatch.setattr(app.state.upstream_client, "_post_json", record_upstream_call) + transport = httpx.ASGITransport(app=app) + message = { + "messageId": "msg-v2-failfast", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + } + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + send_message = await client.post( + "/", + headers={"Authorization": "Bearer test-token"}, + json={ + "jsonrpc": "2.0", + "id": 124, + "method": "SendMessage", + "params": {"message": message}, + }, + ) + send_streaming = await client.post( + "/", + headers={"Authorization": "Bearer test-token"}, + json={ + "jsonrpc": "2.0", + "id": 125, + "method": "SendStreamingMessage", + "params": {"message": message}, + }, + ) + + assert send_message.status_code == 200 + failed_task = send_message.json()["result"]["task"] + assert failed_task["status"]["state"] == "TASK_STATE_FAILED" + assert "No prompt was submitted" in failed_task["status"]["message"]["parts"][0]["text"] + assert send_streaming.status_code == 200 + assert send_streaming.json()["error"]["message"] == "Streaming is not supported by the agent" + assert upstream_calls == [] + + @pytest.mark.asyncio async def test_unsupported_method_uses_requested_protocol_version() -> None: settings = make_settings(test_bearer_token="test-token") diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 8de60e0e..6ed2a94d 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -69,12 +69,22 @@ def test_v2_agent_card_does_not_advertise_unimplemented_chat_or_extensions() -> assert "Only internal experimental OpenCode V2 session transport exists" in card.description assert ( - "A2A message execution and OpenCode-specific extensions are unsupported" in card.description + "A2A SendMessage and SendStreamingMessage fail fast before any upstream request" + in card.description ) + assert "default input/output modes are required SDK schema fields" in card.description assert card.capabilities.streaming is False assert extended_card.capabilities.streaming is False assert card.capabilities.extensions == [] assert extended_card.capabilities.extensions == [] + assert [ + (item.protocol_binding, item.protocol_version) for item in card.supported_interfaces + ] == [ + ("HTTP+JSON", "1.0"), + ("JSONRPC", "1.0"), + ] + assert card.default_input_modes == ["text/plain", "application/octet-stream"] + assert card.default_output_modes == ["text/plain", "application/json"] for v2_card in (card, extended_card): assert [skill.id for skill in v2_card.skills] == ["a2a-execution-unavailable"] skill = v2_card.skills[0] diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index 2a2fe438..d2d70a23 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -253,6 +253,7 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: public_description = _build_agent_card_description( settings, runtime_profile, + capability_snapshot=build_capability_snapshot(runtime_profile=runtime_profile), include_detailed_contracts=False, ) assert "Deployment project: alpha." in public_description @@ -262,6 +263,7 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: extended_description = _build_agent_card_description( settings, runtime_profile, + capability_snapshot=build_capability_snapshot(runtime_profile=runtime_profile), include_detailed_contracts=True, ) assert "Deployment project: alpha." in extended_description From 7845d0ede672c7a80bd2a7a4981abb96b0b299b0 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 15:53:46 +0100 Subject: [PATCH 08/30] test(compatibility): retain session-slice fail-closed wording (#6) --- tests/jsonrpc/test_jsonrpc_unsupported_method.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index 467e1a5c..a7218541 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -192,7 +192,9 @@ async def record_upstream_call(path: str, **_kwargs) -> None: assert send_message.status_code == 200 failed_task = send_message.json()["result"]["task"] assert failed_task["status"]["state"] == "TASK_STATE_FAILED" - assert "No prompt was submitted" in failed_task["status"]["message"]["parts"][0]["text"] + assert "OpenCode V2 prompt execution is not implemented" in ( + failed_task["status"]["message"]["parts"][0]["text"] + ) assert send_streaming.status_code == 200 assert send_streaming.json()["error"]["message"] == "Streaming is not supported by the agent" assert upstream_calls == [] From cc8470d7b631b00e580de741324d5956ef673780 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 15:54:05 +0100 Subject: [PATCH 09/30] style(tests): format session capability assertion --- tests/jsonrpc/test_jsonrpc_unsupported_method.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index a7218541..739749c1 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -192,8 +192,9 @@ async def record_upstream_call(path: str, **_kwargs) -> None: assert send_message.status_code == 200 failed_task = send_message.json()["result"]["task"] assert failed_task["status"]["state"] == "TASK_STATE_FAILED" - assert "OpenCode V2 prompt execution is not implemented" in ( - failed_task["status"]["message"]["parts"][0]["text"] + assert ( + "OpenCode V2 prompt execution is not implemented" + in (failed_task["status"]["message"]["parts"][0]["text"]) ) assert send_streaming.status_code == 200 assert send_streaming.json()["error"]["message"] == "Streaming is not supported by the agent" From b129d3ac87ee0316eb4149f93632166af8f68217 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 17:54:11 +0100 Subject: [PATCH 10/30] feat(upstream): add bounded exclusive V2 execution engine (#4) --- src/opencode_a2a/upstream/v2_events.py | 230 +++++++ src/opencode_a2a/upstream/v2_execution.py | 618 ++++++++++++++++++ tests/upstream/test_v2_creation_barrier.py | 46 ++ tests/upstream/test_v2_execution.py | 369 +++++++++++ tests/upstream/test_v2_execution_edges.py | 311 +++++++++ tests/upstream/test_v2_execution_lifecycle.py | 288 ++++++++ tests/upstream/test_v2_observation_guards.py | 58 ++ 7 files changed, 1920 insertions(+) create mode 100644 src/opencode_a2a/upstream/v2_events.py create mode 100644 src/opencode_a2a/upstream/v2_execution.py create mode 100644 tests/upstream/test_v2_creation_barrier.py create mode 100644 tests/upstream/test_v2_execution.py create mode 100644 tests/upstream/test_v2_execution_edges.py create mode 100644 tests/upstream/test_v2_execution_lifecycle.py create mode 100644 tests/upstream/test_v2_observation_guards.py diff --git a/src/opencode_a2a/upstream/v2_events.py b/src/opencode_a2a/upstream/v2_events.py new file mode 100644 index 00000000..21985bb3 --- /dev/null +++ b/src/opencode_a2a/upstream/v2_events.py @@ -0,0 +1,230 @@ +"""One fail-closed native event connection, shared by gateway-owned sessions. + +The owner must close this hub before closing the existing upstream HTTP client. +There is deliberately no replay or reconnect: losing observation invalidates ownership. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Mapping +from dataclasses import dataclass, field + +from ..opencode_upstream_client import OpencodeUpstreamClient, UpstreamContractError + + +class V2ExecutionUncertainError(UpstreamContractError): + """The invocation cannot be safely attributed; its session must not be reused.""" + + +@dataclass(frozen=True) +class NativeEvent: + id: str + type: str + data: Mapping[str, object] + aggregate: str | None = None + seq: int | None = None + + +def _object(value: object) -> dict[str, object]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise V2ExecutionUncertainError("Native event object is malformed") + return dict(value) + + +@dataclass +class SessionSubscription: + queue: asyncio.Queue[NativeEvent] + error: V2ExecutionUncertainError | None = None + failed: asyncio.Event = field(default_factory=asyncio.Event) + seq: int | None = None + + def check(self) -> None: + if self.error is not None: + raise self.error + + def poison(self, reason: str) -> None: + if self.error is None: + self.error = V2ExecutionUncertainError(reason) + self.failed.set() + + async def receive(self) -> NativeEvent: + self.check() + pending = asyncio.create_task(self.queue.get()) + failed = asyncio.create_task(self.failed.wait()) + try: + await asyncio.wait((pending, failed), return_when=asyncio.FIRST_COMPLETED) + self.check() + return pending.result() + finally: + pending.cancel() + failed.cancel() + await asyncio.gather(pending, failed, return_exceptions=True) + + +class V2EventHub: + """Bounded single-reader fanout using the adapter's HTTP client and stream budget.""" + + def __init__( + self, + client: OpencodeUpstreamClient, + *, + queue_limit: int = 256, + session_limit: int = 1024, + frame_limit: int = 262144, + ready_timeout: float = 10, + ) -> None: + if min(queue_limit, session_limit, frame_limit) < 1 or ready_timeout <= 0: + raise ValueError("Event hub limits must be positive") + self.client = client + self.queue_limit = queue_limit + self.session_limit = session_limit + self.frame_limit = frame_limit + self.ready_timeout = ready_timeout + self.sessions: dict[str, SessionSubscription] = {} + self._ready = asyncio.Event() + self._task: asyncio.Task[None] | None = None + self._error: V2ExecutionUncertainError | None = None + self._closed = False + + def register(self, session: str) -> SessionSubscription: + self.check() + if not session or session in self.sessions: + raise ValueError("Session must be new and nonempty") + if len(self.sessions) >= self.session_limit: + raise V2ExecutionUncertainError("Native event session limit exceeded") + subscription = SessionSubscription(asyncio.Queue(self.queue_limit)) + self.sessions[session] = subscription + return subscription + + def check(self) -> None: + if self._error is not None: + raise self._error + if self._closed: + raise V2ExecutionUncertainError("Native event hub is closed") + + def unregister(self, session: str) -> None: + """Retire observation permanently for an engine-evicted idle session.""" + subscription = self.sessions.pop(session) + subscription.poison("Gateway session ownership was retired") + + async def start(self) -> None: + self.check() + if self._task is None: + self._task = asyncio.create_task(self._read()) + try: + await asyncio.wait_for(self._ready.wait(), self.ready_timeout) + except TimeoutError as exc: + self._fail("Native event server.connected barrier timed out") + if self._task is not None: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + raise V2ExecutionUncertainError("Native event readiness is uncertain") from exc + self.check() + + def _fail(self, reason: str) -> None: + if self._error is None: + self._error = V2ExecutionUncertainError(reason) + for subscription in self.sessions.values(): + subscription.poison(reason) + self._ready.set() + + def _dispatch(self, payload: object) -> None: + envelope = _object(payload) + kind = envelope.get("type") + if kind == "server.connected": + if self._ready.is_set(): + raise V2ExecutionUncertainError("Repeated native event readiness barrier") + self._ready.set() + return + if not self._ready.is_set(): + raise V2ExecutionUncertainError("Native event arrived before server.connected") + identity = envelope.get("id") + if not isinstance(identity, str) or not isinstance(kind, str): + raise V2ExecutionUncertainError("Native event identity is missing") + data = _object(envelope.get("data")) + form = data.get("form") + nested = _object(form) if isinstance(form, dict) else {} + session = data.get("sessionID", nested.get("sessionID")) + durable = envelope.get("durable") + aggregate: str | None = None + seq: int | None = None + if durable is not None: + record = _object(durable) + rawseq = record.get("seq") + rawaggregate = record.get("aggregateID") + aggregate = rawaggregate if isinstance(rawaggregate, str) else None + if isinstance(rawseq, bool) or not ( + isinstance(rawseq, int) or isinstance(rawseq, str) and rawseq.isdecimal() + ): + raise V2ExecutionUncertainError("Native durable sequence is invalid") + seq = int(rawseq) + if aggregate is None or seq < 0: + raise V2ExecutionUncertainError("Native durable aggregate is invalid") + if not isinstance(session, str): + session = aggregate + subscription = self.sessions.get(session) if isinstance(session, str) else None + if subscription is None or subscription.error is not None: + return + if seq is not None: + if aggregate != session or subscription.seq is not None and seq <= subscription.seq: + subscription.poison("Native durable sequence regressed or duplicated") + return + # Sequences may skip records not published on this native feed. + subscription.seq = seq + try: + subscription.queue.put_nowait(NativeEvent(identity, kind, data, aggregate, seq)) + except asyncio.QueueFull: + subscription.poison("Native session event queue overflowed") + + async def _read(self) -> None: + try: + async with self.client._stream_budget.reserve(operation="/api/event"): + async with self.client._client.stream( + "GET", + "/api/event", + timeout=self.client.stream_timeout, + headers=self.client._request_headers({"Accept": "text/event-stream"}), + ) as response: + response.raise_for_status() + if ( + response.headers.get("content-type", "").split(";", 1)[0] + != "text/event-stream" + ): + raise V2ExecutionUncertainError("Native event response is not SSE") + buffer = b"" + async for chunk in response.aiter_bytes(): + # Incremental splitting bounds the parser even for a large HTTP chunk. + for offset in range(0, len(chunk), 4096): + buffer += chunk[offset : offset + 4096] + buffer = buffer.replace(b"\r\n", b"\n") + while b"\n\n" in buffer: + frame, buffer = buffer.split(b"\n\n", 1) + if len(frame) > self.frame_limit: + raise V2ExecutionUncertainError( + "Native event frame limit exceeded" + ) + lines = [ + line[5:].lstrip() + for line in frame.split(b"\n") + if line.startswith(b"data:") + ] + if lines: + self._dispatch(json.loads(b"\n".join(lines))) + if len(buffer) > self.frame_limit: + raise V2ExecutionUncertainError("Native event frame limit exceeded") + self._fail("Native event connection disconnected") + except asyncio.CancelledError: + self._fail("Native event connection stopped") + raise + except Exception: + # Do not expose response payloads, credentials, or model data in errors. + self._fail("Native event observation failed") + + async def close(self) -> None: + self._closed = True + self._fail("Native event hub closed") + if self._task is not None: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) diff --git a/src/opencode_a2a/upstream/v2_execution.py b/src/opencode_a2a/upstream/v2_execution.py new file mode 100644 index 00000000..6bb41fd1 --- /dev/null +++ b/src/opencode_a2a/upstream/v2_execution.py @@ -0,0 +1,618 @@ +"""Exclusive-profile execution for gateway-created native sessions. + +API: register_created(session, directory) only for sessions created by the gateway; +submit(session, text) holds its lease through observed terminal and native settlement; +abort(session, expected_input_id) binds cancellation to that invocation and gates +later admission through interrupt settlement. Call next_update(session) for +V1-shaped text, reasoning and tool events and native intervention events with +``properties=data`` (the owner maps interventions before StreamRuntime). Completed +streams emit session.idle; failed/cancelled streams emit a typed terminal update. +Updates are opt-in with submit(..., emit_updates=True). No retries. Call +ensure_capacity() under the adapter creation lock before upstream session creation; +only fresh server-generated IDs may be passed to register_created(). +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from urllib.parse import quote +from uuid import uuid4 + +from ..opencode_upstream_client import OpencodeUpstreamClient +from .contracts import ( + ExecutionOutcome, + ExecutionState, + ExecutionUpdate, + PromptReceipt, + PromptSubmission, + SessionRef, + UpdateKind, +) +from .v2_events import ( + NativeEvent, + SessionSubscription, + V2EventHub, + V2ExecutionUncertainError, + _object, +) + +_TERMINALS = { + "session.execution.succeeded": ExecutionState.COMPLETED, + "session.execution.failed": ExecutionState.FAILED, + "session.execution.interrupted": ExecutionState.CANCELLED, +} + +V2StreamUpdate = dict[str, object] | ExecutionUpdate + + +@dataclass +class _Invocation: + id: str + event_ids: set[str] = field(default_factory=set) + emit_updates: bool = False + done: asyncio.Event = field(default_factory=asyncio.Event) + enqueued: bool = False + delivered: bool = False + aborting: bool = False + assistants: list[str] = field(default_factory=list) + terminal: NativeEvent | None = None + submission: PromptSubmission | None = None + tools: dict[str, dict[str, object]] = field(default_factory=dict) + dispatched: bool = False + + +@dataclass +class _Session: + directory: str + subscription: SessionSubscription + updates: asyncio.Queue[V2StreamUpdate] + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + invocation: _Invocation | None = None + abort_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class V2ExecutionEngine: + def __init__( + self, + client: OpencodeUpstreamClient, + hub: V2EventHub, + *, + timeout: float = 120, + update_limit: int = 256, + page_limit: int = 20, + max_active: int | None = None, + ) -> None: + if timeout <= 0 or min(update_limit, page_limit) < 1: + raise ValueError("Execution limits must be positive") + self.client = client + self.hub = hub + self.timeout = timeout + self.update_limit = update_limit + self.page_limit = page_limit + self.sessions: dict[str, _Session] = {} + self.max_active = ( + client._settings.opencode_max_concurrent_requests if max_active is None else max_active + ) + if self.max_active < 0: + raise ValueError("Active execution limit must not be negative") + self._active = 0 + + def ensure_capacity(self) -> None: + """Call under the adapter creation lock, before creating a fresh upstream session. + + Only healthy idle ownership is retired. Quarantined ownership remains bounded + by the session limit and cannot be revived by session continuation. + """ + self.hub.check() + if len(self.hub.sessions) < self.hub.session_limit: + return + for session, state in self.sessions.items(): + if ( + state.invocation is None + and not state.lock.locked() + and not state.abort_lock.locked() + and state.subscription.error is None + ): + self.hub.unregister(session) + del self.sessions[session] + return + raise V2ExecutionUncertainError("Native event session capacity is occupied") + + def register_created(self, session: str, directory: str) -> None: + """Register a freshly generated upstream ID, never an existing/continued ID.""" + subscription = self.hub.register(session) + self.sessions[session] = _Session(directory, subscription, asyncio.Queue(self.update_limit)) + + def reject_intervention(self, session: str) -> None: + self._session(session).subscription.poison("Unsupported native intervention") + + def _session(self, session: str) -> _Session: + state = self.sessions.get(session) + if state is None: + raise V2ExecutionUncertainError("Execution requires a gateway-created session") + self.hub.check() + state.subscription.check() + # Dict order is the LRU order; there is no unbounded retired-ID registry. + self.sessions.pop(session) + self.sessions[session] = state + return state + + def validate_intervention( + self, session: str, *, assistant_message_id: str | None = None + ) -> str: + state = self._session(session) + record = state.invocation + if record is None or record.done.is_set() or record.terminal is not None or record.aborting: + raise V2ExecutionUncertainError("Intervention requires an in-flight own invocation") + if assistant_message_id is not None and assistant_message_id not in record.assistants: + raise V2ExecutionUncertainError("Intervention belongs to an unknown assistant step") + return record.id + + def invocation_id(self, session: str) -> str: + record = self._session(session).invocation + if record is None: + raise V2ExecutionUncertainError("No owned invocation remains") + # Cancellation may race a terminal whose projection is still settling. + # Unlike a human reply, it must await that result rather than discard it. + return record.id + + @staticmethod + def _path(session: str) -> str: + return f"/api/session/{quote(session, safe='')}" + + async def _get(self, path: str, state: _Session, **params: str) -> dict[str, object]: + payload = await self.client._get_json( + path, + endpoint=path, + params={"location[directory]": state.directory, **params}, + ) + return _object(payload) + + async def _idle(self, session: str, state: _Session) -> None: + active = await self._get("/api/session/active", state) + if session in _object(active.get("data")): + raise V2ExecutionUncertainError("Session has unfinished or foreign activity") + inbox = await self._get(f"{self._path(session)}/inbox", state) + if inbox.get("data") != []: + raise V2ExecutionUncertainError("Session inbox is not empty") + + def _emit(self, state: _Session, update: V2StreamUpdate) -> None: + if state.invocation is None or not state.invocation.emit_updates: + return + try: + state.updates.put_nowait(update) + except asyncio.QueueFull as exc: + state.subscription.poison("Execution update queue overflowed") + raise V2ExecutionUncertainError("Execution update observation overflowed") from exc + + async def next_update(self, session: str) -> V2StreamUpdate: + state = self._session(session) + pending = asyncio.create_task(state.updates.get()) + failed = asyncio.create_task(state.subscription.failed.wait()) + try: + await asyncio.wait((pending, failed), return_when=asyncio.FIRST_COMPLETED) + state.subscription.check() + return pending.result() + finally: + pending.cancel() + failed.cancel() + await asyncio.gather(pending, failed, return_exceptions=True) + + def _observe( + self, session: str, state: _Session, record: _Invocation, event: NativeEvent + ) -> None: + if event.id in record.event_ids or len(record.event_ids) >= 4096: + raise V2ExecutionUncertainError("Native invocation event identity budget violated") + record.event_ids.add(event.id) + kind = event.type + data = event.data + if kind.startswith("session.inbox."): + if event.seq is None or data.get("inboxID") != record.id: + raise V2ExecutionUncertainError("Observed foreign or unsequenced session input") + if kind == "session.inbox.enqueued": + item = _object(data.get("item")) + if record.enqueued or item.get("type") != "user": + raise V2ExecutionUncertainError("Observed duplicate or synthetic session input") + record.enqueued = True + return + if kind == "session.inbox.delivered": + if not record.enqueued or record.delivered: + raise V2ExecutionUncertainError("Own input delivery order is uncertain") + record.delivered = True + return + if kind == "session.inbox.cancelled" and record.aborting and not record.delivered: + # Pending deletion is not execution interruption or completion evidence. + return + raise V2ExecutionUncertainError("Unexpected session inbox mutation") + if kind in { + "session.synthetic", + "session.compacted", + "session.moved", + "session.deleted", + } or kind.startswith("session.shell."): + raise V2ExecutionUncertainError("Unexpected synthetic or external session work") + if kind == "session.step.started": + identity = data.get("assistantMessageID") + if not record.delivered or event.seq is None or not isinstance(identity, str): + raise V2ExecutionUncertainError("Assistant step has no observed own delivery") + if identity not in record.assistants: + record.assistants.append(identity) + return + identity = data.get("assistantMessageID") + if identity is not None and identity not in record.assistants: + raise V2ExecutionUncertainError("Observed unknown assistant step") + if kind in _TERMINALS: + if not record.delivered or not record.assistants or event.seq is None: + raise V2ExecutionUncertainError("Terminal has no observed own assistant execution") + if kind == "session.execution.failed" and not isinstance(data.get("error"), dict): + raise V2ExecutionUncertainError("Failed terminal has no error record") + if kind == "session.execution.interrupted" and ( + data.get("reason") != "user" or not record.aborting + ): + raise V2ExecutionUncertainError( + "Session interrupted by an unowned lifecycle change" + ) + record.terminal = event + return + if kind in { + "session.text.delta", + "session.text.ended", + "session.reasoning.started", + "session.reasoning.delta", + "session.reasoning.ended", + }: + if not isinstance(identity, str) or identity not in record.assistants: + raise V2ExecutionUncertainError("Text has no observed assistant step") + ordinal = data.get("ordinal") + if isinstance(ordinal, bool) or not isinstance(ordinal, int) or ordinal < 0: + raise V2ExecutionUncertainError("Text part ordinal is missing") + part_type = "reasoning" if kind.startswith("session.reasoning.") else "text" + part = f"{identity}:{part_type}:{ordinal}" + if kind.endswith(".delta"): + delta = data.get("delta") + if not isinstance(delta, str): + raise V2ExecutionUncertainError("Text delta is malformed") + self._emit( + state, + { + "type": "message.part.delta", + "properties": { + "sessionID": session, + "messageID": identity, + "partID": part, + "field": "text", + "delta": delta, + }, + }, + ) + return + text = "" if kind.endswith(".started") else data.get("text") + if not isinstance(text, str): + raise V2ExecutionUncertainError("Text snapshot is malformed") + self._emit( + state, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": session, + "messageID": identity, + "id": part, + "type": part_type, + "text": text, + } + }, + }, + ) + return + if kind.startswith("session.tool."): + self._tool(session, state, record, event) + return + if kind.startswith(("form.", "permission.")): + if kind in {"form.created", "permission.asked"} and not record.emit_updates: + raise V2ExecutionUncertainError( + "Unary interventions are unsupported; session quarantined without approval" + ) + self._emit(state, {"type": kind, "properties": dict(data)}) + + def _tool(self, session: str, state: _Session, record: _Invocation, event: NativeEvent) -> None: + """Map the pinned Tool.Input/Called/Progress/Success/Failed schemas.""" + data = event.data + identity = data.get("assistantMessageID") + call = data.get("id") + if not isinstance(identity, str) or identity not in record.assistants: + raise V2ExecutionUncertainError("Tool has no observed assistant step") + if not isinstance(call, str) or not call: + raise V2ExecutionUncertainError("Tool call identity is missing") + key = f"{identity}:tool:{call}" + previous = record.tools.get(key) + if event.type == "session.tool.input.started": + name = data.get("name") + if previous is not None or not isinstance(name, str) or not name: + raise V2ExecutionUncertainError("Tool name is missing or call is duplicated") + part: dict[str, object] = { + "sessionID": session, + "messageID": identity, + "id": key, + "type": "tool", + "callID": call, + "tool": name, + "state": {"status": "pending", "input": {}}, + } + else: + if previous is None: + raise V2ExecutionUncertainError("Tool event has no observed call start") + part = dict(previous) + toolstate = dict(_object(part["state"])) + if toolstate.get("status") in {"completed", "error"}: + raise V2ExecutionUncertainError("Tool changed after its terminal event") + if event.type == "session.tool.called": + toolstate.update(status="running", input=_object(data.get("input"))) + elif event.type == "session.tool.progress": + toolstate["metadata"] = _object(data.get("metadata")) + elif event.type in {"session.tool.success", "session.tool.failed"}: + content = data.get("content") + if event.type == "session.tool.success" or content is not None: + if not isinstance(content, list) or not content: + raise V2ExecutionUncertainError("Tool terminal content is malformed") + chunks = [_object(chunk) for chunk in content] + for chunk in chunks: + if chunk.get("type") == "text" and isinstance(chunk.get("text"), str): + continue + if ( + chunk.get("type") == "file" + and isinstance(chunk.get("uri"), str) + and isinstance(chunk.get("mime"), str) + ): + continue + raise V2ExecutionUncertainError("Tool terminal content is malformed") + # Preserve file content alongside text in the structured artifact. + toolstate["output"] = chunks + toolstate["status"] = ( + "completed" if event.type == "session.tool.success" else "error" + ) + if event.type == "session.tool.failed": + toolstate["error"] = _object(data.get("error")) + if "metadata" in data: + toolstate["metadata"] = _object(data["metadata"]) + elif event.type in {"session.tool.input.delta", "session.tool.input.ended"}: + return + else: + raise V2ExecutionUncertainError("Unknown native tool event") + part["state"] = toolstate + record.tools[key] = part + self._emit(state, {"type": "message.part.updated", "properties": {"part": part}}) + + async def submit( + self, session: str, text: str, *, emit_updates: bool = False + ) -> PromptSubmission: + state = self._session(session) + if not text.strip(): + raise ValueError("Prompt text must not be empty") + async with state.lock: + async with state.abort_lock: + if self._session(session) is not state: + raise V2ExecutionUncertainError("Session ownership was retired") + if self.max_active and self._active >= self.max_active: + raise V2ExecutionUncertainError("Active execution capacity is occupied") + self._active += 1 + record = _Invocation(f"msg_{uuid4().hex}", emit_updates=emit_updates) + try: + async with asyncio.timeout(self.timeout): + self._session(session) + while not state.updates.empty(): + state.updates.get_nowait() + await self.hub.start() + await self._idle(session, state) + state.subscription.check() + while not state.subscription.queue.empty(): + baseline = state.subscription.queue.get_nowait() + if ( + baseline.type != "session.created" + or baseline.data.get("sessionID") != session + ): + raise V2ExecutionUncertainError( + "Session changed outside its invocation lease" + ) + state.invocation = record + path = f"{self._path(session)}/prompt" + record.dispatched = True + response = _object( + await self.client._post_json( + path, + endpoint=path, + params={"location[directory]": state.directory}, + json_body={ + "id": record.id, + "text": text, + "delivery": "queue", + "resume": True, + }, + ) + ) + if _object(response.get("data")).get("id") != record.id: + raise V2ExecutionUncertainError( + "Prompt receipt does not match admitted input" + ) + while record.terminal is None: + event = await state.subscription.receive() + self._observe(session, state, record, event) + submission = await self._settle(session, state, record) + state.subscription.check() + if not state.subscription.queue.empty(): + raise V2ExecutionUncertainError( + "Session changed after its observed terminal" + ) + if ( + submission.outcome is not None + and submission.outcome.state == ExecutionState.COMPLETED + ): + self._emit( + state, {"type": "session.idle", "properties": {"sessionID": session}} + ) + else: + self._emit( + state, + ExecutionUpdate( + UpdateKind.OTHER, + session_id=session, + terminal=submission.outcome, + ), + ) + record.submission = submission + return submission + except asyncio.CancelledError: + state.subscription.poison("Invocation owner cancelled before proven settlement") + raise + except Exception as exc: + state.subscription.poison("Invocation attribution or settlement is uncertain") + if isinstance(exc, V2ExecutionUncertainError): + raise + raise V2ExecutionUncertainError( + "Invocation failed without proven settlement; no retry" + ) from exc + finally: + record.done.set() + state.invocation = None + # Uncertain dispatched work can still be running natively. Its + # quarantined slot must not admit unbounded replacement work. + if not record.dispatched or record.submission is not None: + self._active -= 1 + + async def _settle(self, session: str, state: _Session, record: _Invocation) -> PromptSubmission: + terminal = record.terminal + if terminal is None: + raise V2ExecutionUncertainError("Settlement requires an observed terminal") + path = f"/api/experimental/session/{quote(session, safe='')}/wait" + async with self.client._request_budget.reserve(operation=path): + response = await self.client._client.post( + path, + params={"location[directory]": state.directory}, + headers=self.client._request_headers(), + ) + response.raise_for_status() + if response.status_code != 204: + raise V2ExecutionUncertainError("Native wait did not settle") + await self._idle(session, state) + messages: dict[str, dict[str, object]] = {} + cursor: str | None = None + idle_id = "msg_" + terminal.id[4:] if terminal.id.startswith("evt_") else terminal.id + required = {record.id, idle_id, *record.assistants} + for _ in range(self.page_limit): + params = {"cursor": cursor} if cursor else {"order": "desc"} + page = await self._get(f"{self._path(session)}/message", state, limit="100", **params) + items = page.get("data") + if not isinstance(items, list) or len(items) > 100: + raise V2ExecutionUncertainError("Message projection page is malformed") + for raw in items: + item = _object(raw) + identity = item.get("id") + if not isinstance(identity, str) or identity in messages: + raise V2ExecutionUncertainError( + "Message projection identity is duplicated or missing" + ) + messages[identity] = item + if required <= messages.keys(): + break + next_cursor = _object(page.get("cursor", {})).get("next") + if not items or not isinstance(next_cursor, str) or next_cursor == cursor: + raise V2ExecutionUncertainError("Exact invocation projection is missing") + cursor = next_cursor + if not required <= messages.keys(): + raise V2ExecutionUncertainError("Message projection page budget exhausted") + outcome_name = terminal.type.rsplit(".", 1)[-1] + if ( + messages[record.id].get("type") != "user" + or messages[idle_id].get("type") != "idle" + or messages[idle_id].get("outcome") != outcome_name + ): + raise V2ExecutionUncertainError("Persisted input or terminal outcome does not match") + output: list[str] = [] + for identity in record.assistants: + assistant = messages[identity] + if assistant.get("type") != "assistant": + raise V2ExecutionUncertainError("Observed step projection is not an assistant") + content = assistant.get("content") + if not isinstance(content, list): + raise V2ExecutionUncertainError("Observed assistant content is missing") + for raw in content: + part = _object(raw) + if part.get("type") == "text": + text = part.get("text") + if not isinstance(text, str): + raise V2ExecutionUncertainError("Persisted assistant text is malformed") + output.append(text) + outcome = ExecutionOutcome( + SessionRef(session), + _TERMINALS[terminal.type], + "\n".join(output), + message_id=record.assistants[-1], + error_type="upstream_execution_failed" if outcome_name == "failed" else None, + error_message="Upstream execution failed" if outcome_name == "failed" else None, + ) + return PromptSubmission(PromptReceipt(SessionRef(session), record.id), outcome) + + async def abort(self, session: str, expected_input_id: str | None = None) -> PromptSubmission: + state = self._session(session) + record = state.invocation + if ( + record is None + or record.aborting + or expected_input_id is not None + and record.id != expected_input_id + ): + raise V2ExecutionUncertainError("Abort requires a known in-flight own invocation") + record.aborting = True + # Acquire synchronously before yielding: natural settlement may release the + # invocation lease while the session-wide interrupt HTTP request is in flight. + await state.abort_lock.acquire() + try: + async with asyncio.timeout(self.timeout): + path = self._path(session) + if not record.delivered: + deletion = f"{path}/inbox/{quote(record.id, safe='')}" + async with self.client._request_budget.reserve(operation=deletion): + if not record.done.is_set() and record.terminal is None: + response = await self.client._client.delete( + deletion, + params={"location[directory]": state.directory}, + headers=self.client._request_headers(), + ) + response.raise_for_status() + if response.status_code != 204: + raise V2ExecutionUncertainError( + "Pending input deletion did not settle" + ) + async with self.client._request_budget.reserve(operation=f"{path}/interrupt"): + self.hub.check() + state.subscription.check() + if not record.done.is_set() and record.terminal is None: + if state.invocation is not record: + raise V2ExecutionUncertainError("Abort invocation ownership changed") + response = await self.client._client.post( + f"{path}/interrupt", + params={"resume": "false", "location[directory]": state.directory}, + headers=self.client._request_headers(), + ) + response.raise_for_status() + response_data = _object(response.json()) + if not isinstance(response_data.get("interrupted"), bool): + raise V2ExecutionUncertainError("Native interrupt receipt is malformed") + await record.done.wait() + state.subscription.check() + if record.submission is None: + raise V2ExecutionUncertainError("Abort has no observed settled outcome") + return record.submission + except asyncio.CancelledError: + state.subscription.poison("Abort did not prove invocation settlement") + raise + except Exception as exc: + state.subscription.poison("Abort did not prove invocation settlement") + if isinstance(exc, V2ExecutionUncertainError): + raise + raise V2ExecutionUncertainError( + "Abort failed without proven settlement; no retry" + ) from exc + finally: + state.abort_lock.release() diff --git a/tests/upstream/test_v2_creation_barrier.py b/tests/upstream/test_v2_creation_barrier.py new file mode 100644 index 00000000..61cadb7f --- /dev/null +++ b/tests/upstream/test_v2_creation_barrier.py @@ -0,0 +1,46 @@ +import asyncio + +import pytest + +from opencode_a2a.upstream.contracts import ExecutionState +from opencode_a2a.upstream.v2_events import V2ExecutionUncertainError +from tests.upstream.test_v2_execution import NativeServer + + +@pytest.mark.asyncio +async def test_own_late_creation_event_does_not_block_first_admission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + try: + await server.hub.start() + server.feed.send(server.event("ses_one", "session.created")) + async with asyncio.timeout(1): + while server.hub.sessions["ses_one"].queue.empty(): + await asyncio.sleep(0) + result = await server.engine.submit("ses_one", "first") + assert result.outcome is not None and result.outcome.state == ExecutionState.COMPLETED + assert len(server.inputs) == 1 + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_unknown_baseline_work_still_blocks_admission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + try: + await server.hub.start() + server.feed.send( + server.event("ses_one", "session.step.started", assistantMessageID="foreign") + ) + async with asyncio.timeout(1): + while server.hub.sessions["ses_one"].queue.empty(): + await asyncio.sleep(0) + with pytest.raises(V2ExecutionUncertainError, match="outside"): + await server.engine.submit("ses_one", "first") + assert server.inputs == [] + assert server.hub.sessions["ses_one"].error is not None + finally: + await server.close() diff --git a/tests/upstream/test_v2_execution.py b/tests/upstream/test_v2_execution.py new file mode 100644 index 00000000..2c8ff8ce --- /dev/null +++ b/tests/upstream/test_v2_execution.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator + +import httpx +import pytest + +from opencode_a2a.upstream.contracts import ExecutionState +from opencode_a2a.upstream.v2 import V2UpstreamAdapter +from opencode_a2a.upstream.v2_events import V2EventHub, V2ExecutionUncertainError +from opencode_a2a.upstream.v2_execution import V2ExecutionEngine +from tests.support.settings import make_settings + + +class EventStream(httpx.AsyncByteStream): + def __init__(self) -> None: + self.queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self.closed = False + + def send(self, payload: dict[str, object]) -> None: + self.queue.put_nowait(b"data: " + json.dumps(payload).encode() + b"\n\n") + + async def __aiter__(self) -> AsyncIterator[bytes]: + while True: + chunk = await self.queue.get() + if chunk is None: + return + yield chunk + + async def aclose(self) -> None: + self.closed = True + + +class NativeServer: + def __init__(self, monkeypatch: pytest.MonkeyPatch, mode: str = "success") -> None: + self.mode = mode + self.feed = EventStream() + self.feed.send({"type": "server.connected"}) + self.requests: list[httpx.Request] = [] + self.messages: dict[str, list[dict[str, object]]] = {} + self.inputs: list[str] = [] + self.sequences: dict[str, int] = {} + self.admitted = asyncio.Event() + self.client = V2UpstreamAdapter(make_settings()) + monkeypatch.setattr(self.client._client, "_transport", httpx.MockTransport(self.handle)) + self.hub = V2EventHub(self.client, ready_timeout=0.1) + self.engine = V2ExecutionEngine(self.client, self.hub, timeout=0.5) + self.engine.register_created("ses_one", "/workspace") + + def event(self, session: str, kind: str, **data: object) -> dict[str, object]: + seq = self.sequences.get(session, 0) + 3 + self.sequences[session] = seq + return { + "id": f"evt_{session}_{seq}", + "type": kind, + "data": {"sessionID": session, **data}, + "durable": {"aggregateID": session, "seq": str(seq)}, + } + + def execution(self, session: str, identity: str, *, interrupted: bool = False) -> None: + assistant = f"msg_assistant_{len(self.inputs)}_{session}" + events = [ + self.event(session, "session.inbox.enqueued", inboxID=identity, item={"type": "user"}), + self.event(session, "session.inbox.delivered", inboxID=identity), + self.event(session, "session.step.started", assistantMessageID=assistant), + self.event( + session, "session.text.ended", assistantMessageID=assistant, ordinal=0, text=session + ), + ] + terminal = ( + "interrupted" if interrupted else "failed" if self.mode == "failed" else "succeeded" + ) + end = self.event( + session, f"session.execution.{terminal}", reason="user", error={"type": "provider"} + ) + events.append(end) + if self.mode == "missing-delivery": + events.pop(1) + if self.mode == "duplicate": + events.insert(1, events[0]) + if self.mode == "reorder": + events[1], events[2] = events[2], events[1] + if self.mode == "foreign-input": + events.insert( + 1, + self.event( + session, "session.inbox.enqueued", inboxID="msg_foreign", item={"type": "user"} + ), + ) + if self.mode == "synthetic": + events[0] = self.event( + session, "session.inbox.enqueued", inboxID=identity, item={"type": "synthetic"} + ) + if self.mode == "missing-terminal": + events.pop() + if self.mode == "unsequenced-terminal": + end.pop("durable") + if self.mode == "foreign-step": + events[3] = self.event( + session, + "session.text.ended", + assistantMessageID="msg_foreign", + ordinal=0, + text="bad", + ) + if self.mode == "delta": + events.insert( + 3, + { + "id": f"evt_delta_{session}", + "type": "session.text.delta", + "data": { + "sessionID": session, + "assistantMessageID": assistant, + "ordinal": 0, + "delta": session, + }, + }, + ) + baseline = self.sequences[session] + for index, event in enumerate(events, start=1): + if "durable" in event: + seq = baseline + index * 3 + event["id"] = f"evt_{session}_{seq}" + event["durable"] = {"aggregateID": session, "seq": str(seq)} + self.sequences[session] = seq + self.messages[session] = [ + {"id": str(end["id"]).replace("evt_", "msg_", 1), "type": "idle", "outcome": terminal}, + {"id": assistant, "type": "assistant", "content": [{"type": "text", "text": session}]}, + {"id": identity, "type": "user"}, + { + "id": "msg_unrelated", + "type": "assistant", + "content": [{"type": "text", "text": "foreign"}], + }, + ] + if self.mode == "missing-projection": + self.messages[session].pop(1) + if self.mode == "wrong-idle": + self.messages[session][0]["outcome"] = "failed" + for event in events: + self.feed.send(event) + + async def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + session = path.split("/")[3] if path.startswith("/api/session/ses_") else "ses_one" + if path == "/api/event": + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=self.feed + ) + if path == "/api/session/active": + return httpx.Response( + 200, + json={"data": {"ses_one": {"type": "running"}} if self.mode == "active" else {}}, + ) + if path.endswith("/inbox"): + return httpx.Response( + 200, json={"data": [{"id": "foreign"}] if self.mode == "inbox" else []} + ) + if path.endswith("/wait") or request.method == "DELETE": + return httpx.Response(204) + if path.endswith("/message"): + return httpx.Response(200, json={"data": self.messages[session], "cursor": {}}) + if path.endswith("/interrupt"): + self.execution(session, self.inputs[-1], interrupted=True) + return httpx.Response(200, json={"interrupted": True}) + if path.endswith("/prompt"): + body = json.loads(request.content) + identity = body["id"] + self.inputs.append(identity) + self.admitted.set() + if self.mode == "disconnect": + self.feed.queue.put_nowait(None) + elif self.mode != "waiting": + self.execution(session, identity) + if self.mode == "http-error": + return httpx.Response(503) + return httpx.Response( + 200, json={"data": {"id": "msg_wrong" if self.mode == "bad-receipt" else identity}} + ) + raise AssertionError(f"Unexpected HTTP boundary: {request.method} {path}") + + async def close(self) -> None: + await self.hub.close() + await self.client.close() + + +@pytest.mark.asyncio +async def test_native_success_exact_projection_and_followup_unique_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + try: + first = await server.engine.submit("ses_one", "first", emit_updates=True) + second = await server.engine.submit("ses_one", "followup", emit_updates=True) + assert first.outcome is not None and first.outcome.state == ExecutionState.COMPLETED + assert first.outcome.output == "ses_one" and second.outcome == first.outcome.__class__( + first.outcome.session, + ExecutionState.COMPLETED, + "ses_one", + message_id="msg_assistant_2_ses_one", + ) + assert first.receipt.message_id != second.receipt.message_id + assert len(set(server.inputs)) == 2 and all( + identity.startswith("msg_") for identity in server.inputs + ) + assert sum(request.url.path == "/api/event" for request in server.requests) == 1 + prompts = [ + json.loads(request.content) + for request in server.requests + if request.url.path.endswith("/prompt") + ] + assert prompts[0] == { + "id": first.receipt.message_id, + "text": "first", + "delivery": "queue", + "resume": True, + } + assert any( + request.url.path == "/api/experimental/session/ses_one/wait" and not request.content + for request in server.requests + ) + update = await server.engine.next_update("ses_one") + assert isinstance(update, dict) + assert update["type"] == "message.part.updated" and "properties" in update + terminal = await server.engine.next_update("ses_one") + assert terminal == {"type": "session.idle", "properties": {"sessionID": "ses_one"}} + finally: + await server.close() + assert server.feed.closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mode", + [ + "missing-delivery", + "duplicate", + "reorder", + "foreign-input", + "synthetic", + "missing-terminal", + "unsequenced-terminal", + "foreign-step", + "missing-projection", + "wrong-idle", + "http-error", + "bad-receipt", + "disconnect", + "active", + "inbox", + ], +) +async def test_uncertainty_quarantines_without_blind_resend( + monkeypatch: pytest.MonkeyPatch, mode: str +) -> None: + server = NativeServer(monkeypatch, mode) + try: + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "test") + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "retry forbidden") + assert len(server.requests) == count + assert len(server.inputs) == (0 if mode in {"active", "inbox"} else 1) + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_failed_native_terminal_is_an_attributed_failed_outcome( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "failed") + try: + result = await server.engine.submit("ses_one", "test") + assert result.outcome is not None and result.outcome.state == ExecutionState.FAILED + assert ( + result.outcome.output == "ses_one" + and result.outcome.error_type == "upstream_execution_failed" + ) + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_parallel_sessions_share_feed_and_keep_output_isolated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + server.engine.register_created("ses_two", "/workspace") + try: + first, second = await asyncio.gather( + server.engine.submit("ses_one", "one"), server.engine.submit("ses_two", "two") + ) + assert first.outcome is not None and first.outcome.output == "ses_one" + assert second.outcome is not None and second.outcome.output == "ses_two" + assert len(server.inputs) == 2 and len(set(server.inputs)) == 2 + assert sum(request.url.path == "/api/event" for request in server.requests) == 1 + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_unknown_session_and_abort_without_lease_have_no_http( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + try: + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_foreign", "test") + with pytest.raises(V2ExecutionUncertainError): + await server.engine.abort("ses_one") + assert server.requests == [] + assert server.inputs == [] + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_abort_returns_only_observed_interrupted_settlement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + task = asyncio.create_task(server.engine.submit("ses_one", "wait")) + try: + await server.admitted.wait() + result = await server.engine.abort("ses_one") + assert await task == result + assert result.outcome is not None and result.outcome.state == ExecutionState.CANCELLED + interrupt = next( + request for request in server.requests if request.url.path.endswith("/interrupt") + ) + assert interrupt.url.params["resume"] == "false" and not interrupt.content + assert sum(request.method == "DELETE" for request in server.requests) == 1 + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit", ["events", "updates", "frame", "barrier"]) +async def test_bounded_queues_frames_and_readiness_fail_closed( + monkeypatch: pytest.MonkeyPatch, limit: str +) -> None: + server = NativeServer(monkeypatch) + if limit == "events": + server.engine.sessions["ses_one"].subscription.queue = asyncio.Queue(1) + if limit == "updates": + server.engine.sessions["ses_one"].updates = asyncio.Queue(1) + if limit == "frame": + server.hub.frame_limit = 10 + if limit == "barrier": + server.feed.queue.get_nowait() + try: + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "test", emit_updates=True) + assert server.engine.sessions["ses_one"].subscription.error is not None + assert len(server.inputs) == (0 if limit in {"barrier", "frame"} else 1) + if limit == "barrier": + assert server.hub._task is not None and server.hub._task.done() + assert server.feed.closed + assert server.client._stream_budget._inflight == 0 + finally: + await server.close() diff --git a/tests/upstream/test_v2_execution_edges.py b/tests/upstream/test_v2_execution_edges.py new file mode 100644 index 00000000..7b05a50e --- /dev/null +++ b/tests/upstream/test_v2_execution_edges.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Mapping + +import httpx +import pytest + +from opencode_a2a.upstream.contracts import ExecutionState, ExecutionUpdate +from opencode_a2a.upstream.v2_events import V2ExecutionUncertainError +from tests.upstream.test_v2_execution import NativeServer + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["delete-status", "interrupt-http", "interrupt-shape"]) +async def test_abort_failure_is_explicit_uncertainty_and_poisons_the_invocation( + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + server = NativeServer(monkeypatch, "waiting") + + async def broken_abort(request: httpx.Request) -> httpx.Response: + if request.method == "DELETE" and failure == "delete-status": + return httpx.Response(200) + if request.url.path.endswith("/interrupt"): + return ( + httpx.Response(503) + if failure == "interrupt-http" + else httpx.Response(200, json={"interrupted": "yes"}) + ) + return await server.handle(request) + + monkeypatch.setattr(server.client._client, "_transport", httpx.MockTransport(broken_abort)) + task = asyncio.create_task(server.engine.submit("ses_one", "test")) + try: + await server.admitted.wait() + with pytest.raises(V2ExecutionUncertainError): + await server.engine.abort("ses_one") + results = await asyncio.wait_for(asyncio.gather(task, return_exceptions=True), 1) + assert len(results) == 1 and isinstance(results[0], V2ExecutionUncertainError) + assert server.engine.sessions["ses_one"].subscription.error is not None + assert len(server.inputs) == 1 + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_native_ephemeral_delta_and_snapshot_keep_one_part_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "delta") + try: + result = await server.engine.submit("ses_one", "test", emit_updates=True) + delta = await server.engine.next_update("ses_one") + snapshot = await server.engine.next_update("ses_one") + assert isinstance(delta, dict) and delta["type"] == "message.part.delta" + assert isinstance(snapshot, dict) and snapshot["type"] == "message.part.updated" + properties = delta["properties"] + updated = snapshot["properties"] + assert isinstance(properties, Mapping) and isinstance(updated, Mapping) + part = updated["part"] + assert isinstance(part, Mapping) and properties["partID"] == part["id"] + assert properties["delta"] == "ses_one" and properties["field"] == "text" + assert result.outcome is not None and result.outcome.output == "ses_one" + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_failed_stream_emits_typed_failed_terminal_only_after_settlement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "failed") + try: + result = await server.engine.submit("ses_one", "test", emit_updates=True) + await server.engine.next_update("ses_one") + terminal = await server.engine.next_update("ses_one") + assert isinstance(terminal, ExecutionUpdate) + assert terminal.terminal == result.outcome + assert terminal.terminal is not None and terminal.terminal.state == ExecutionState.FAILED + assert server.engine.sessions["ses_one"].updates.empty() + finally: + await server.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["session.synthetic", "session.shell.started"]) +async def test_unexpected_idle_work_prevents_admission( + monkeypatch: pytest.MonkeyPatch, + kind: str, +) -> None: + server = NativeServer(monkeypatch) + try: + await server.hub.start() + server.feed.send(server.event("ses_one", kind)) + subscription = server.engine.sessions["ses_one"].subscription + # Observe the actual HTTP event feed before attempting admission. + for _ in range(20): + if not subscription.queue.empty(): + break + await asyncio.sleep(0) + assert subscription.queue.qsize() == 1 + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "test") + assert server.inputs == [] + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_unary_has_no_updates_and_new_stream_drops_previous_invocation_updates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + try: + for _ in range(3): + await server.engine.submit("ses_one", "unary") + state = server.engine.sessions["ses_one"] + assert state.updates.empty() + await server.engine.submit("ses_one", "old stream", emit_updates=True) + assert state.updates.qsize() == 2 + await server.engine.submit("ses_one", "new stream", emit_updates=True) + assert state.updates.qsize() == 2 + update = await server.engine.next_update("ses_one") + assert isinstance(update, dict) + properties = update["properties"] + assert isinstance(properties, Mapping) + part = properties["part"] + assert isinstance(part, Mapping) and part["messageID"] == "msg_assistant_5_ses_one" + assert len(server.inputs) == 5 and len(set(server.inputs)) == 5 + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_same_session_lease_serializes_invocations(monkeypatch: pytest.MonkeyPatch) -> None: + server = NativeServer(monkeypatch, "waiting") + first = asyncio.create_task(server.engine.submit("ses_one", "first")) + second: asyncio.Task[object] | None = None + try: + await server.admitted.wait() + second = asyncio.create_task(server.engine.submit("ses_one", "second")) + await asyncio.sleep(0) + assert len(server.inputs) == 1 + server.mode = "success" + server.execution("ses_one", server.inputs[0]) + first_result, second_result = await asyncio.gather(first, second) + assert first_result.receipt.message_id != second_result.receipt.message_id + assert len(server.inputs) == 2 + finally: + first.cancel() + if second is not None: + second.cancel() + await asyncio.gather(second, return_exceptions=True) + await asyncio.gather(first, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_disconnect_wakes_invocation_and_update_listener_without_reconnect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + submission = asyncio.create_task(server.engine.submit("ses_one", "test", emit_updates=True)) + listener = asyncio.create_task(server.engine.next_update("ses_one")) + try: + await server.admitted.wait() + server.feed.queue.put_nowait(None) + results = await asyncio.wait_for( + asyncio.gather(submission, listener, return_exceptions=True), 1 + ) + assert len(results) == 2 and all( + isinstance(result, V2ExecutionUncertainError) for result in results + ) + assert sum(request.url.path == "/api/event" for request in server.requests) == 1 + assert len(server.inputs) == 1 + finally: + submission.cancel() + listener.cancel() + await asyncio.gather(submission, listener, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_invocation_owner_cancellation_quarantines_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + task = asyncio.create_task(server.engine.submit("ses_one", "test")) + try: + await server.admitted.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "retry") + assert len(server.requests) == count + assert len(server.inputs) == 1 + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_native_form_nested_session_and_resolution_fanout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + task = asyncio.create_task(server.engine.submit("ses_one", "test", emit_updates=True)) + try: + await server.admitted.wait() + server.feed.send( + { + "id": "evt_form", + "type": "form.created", + "data": {"form": {"id": "form_one", "sessionID": "ses_one", "fields": []}}, + } + ) + created = await asyncio.wait_for(server.engine.next_update("ses_one"), 1) + assert isinstance(created, dict) and created["type"] == "form.created" + assert created["properties"] == { + "form": {"id": "form_one", "sessionID": "ses_one", "fields": []} + } + server.feed.send( + { + "id": "evt_reply", + "type": "form.replied", + "data": {"id": "form_one", "sessionID": "ses_one", "answer": {"q0": "Yes"}}, + } + ) + resolved = await asyncio.wait_for(server.engine.next_update("ses_one"), 1) + assert isinstance(resolved, dict) and resolved["type"] == "form.replied" + assert resolved["properties"] == { + "id": "form_one", + "sessionID": "ses_one", + "answer": {"q0": "Yes"}, + } + server.execution("ses_one", server.inputs[0]) + result = await task + assert result.outcome is not None and result.outcome.output == "ses_one" + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_projection_pagination_is_bounded_and_cursor_does_not_combine_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + pages = 0 + + async def paginated(request: httpx.Request) -> httpx.Response: + nonlocal pages + if request.url.path.endswith("/message"): + pages += 1 + if pages == 1: + assert request.url.params["order"] == "desc" + return httpx.Response( + 200, + json={"data": [server.messages["ses_one"][0]], "cursor": {"next": "opaque"}}, + ) + assert request.url.params["cursor"] == "opaque" and "order" not in request.url.params + return httpx.Response(200, json={"data": server.messages["ses_one"][1:], "cursor": {}}) + return await server.handle(request) + + monkeypatch.setattr(server.client._client, "_transport", httpx.MockTransport(paginated)) + try: + result = await server.engine.submit("ses_one", "test") + assert result.outcome is not None and result.outcome.output == "ses_one" + assert pages == 2 + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_no_terminal_update_escapes_when_exact_projection_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "missing-projection") + try: + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "test", emit_updates=True) + queue = server.engine.sessions["ses_one"].updates + update = queue.get_nowait() + assert isinstance(update, dict) and update["type"] == "message.part.updated" + assert queue.empty() + finally: + await server.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("category", ["stream", "request"]) +async def test_existing_upstream_concurrency_budgets_are_not_bypassed( + monkeypatch: pytest.MonkeyPatch, + category: str, +) -> None: + server = NativeServer(monkeypatch) + budget = server.client._stream_budget if category == "stream" else server.client._request_budget + monkeypatch.setattr(budget, "_limit", 1) + try: + async with budget.reserve(operation="occupied"): + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "test") + assert server.inputs == [] + assert server.engine.sessions["ses_one"].subscription.error is not None + finally: + await server.close() diff --git a/tests/upstream/test_v2_execution_lifecycle.py b/tests/upstream/test_v2_execution_lifecycle.py new file mode 100644 index 00000000..ca203fc5 --- /dev/null +++ b/tests/upstream/test_v2_execution_lifecycle.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from opencode_a2a.execution.stream_events import normalize_v1_event +from opencode_a2a.upstream.v2_events import V2ExecutionUncertainError +from tests.upstream.test_v2_execution import NativeServer + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prerequisite", ["interrupt", "delete"]) +async def test_abort_barrier_outlives_natural_completion( + monkeypatch: pytest.MonkeyPatch, prerequisite: str +) -> None: + server = NativeServer(monkeypatch, "waiting") + entered = asyncio.Event() + release = asyncio.Event() + interrupts: list[str] = [] + + async def delayed(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/interrupt"): + interrupts.append(request.url.path) + if prerequisite == "interrupt": + entered.set() + await release.wait() + return httpx.Response(200, json={"interrupted": False}) + if request.method == "DELETE" and prerequisite == "delete": + entered.set() + await release.wait() + return httpx.Response(204) + return await server.handle(request) + + monkeypatch.setattr(server.client._client, "_transport", httpx.MockTransport(delayed)) + first = asyncio.create_task(server.engine.submit("ses_one", "A")) + abort: asyncio.Task[object] | None = None + second: asyncio.Task[object] | None = None + try: + await server.admitted.wait() + abort = asyncio.create_task(server.engine.abort("ses_one", server.inputs[0])) + await asyncio.wait_for(entered.wait(), 1) + server.execution("ses_one", server.inputs[0]) + result = await asyncio.wait_for(first, 1) + server.mode = "success" + second = asyncio.create_task(server.engine.submit("ses_one", "B")) + for _ in range(5): + await asyncio.sleep(0) + assert len(server.inputs) == 1 + assert not second.done() and not abort.done() + release.set() + assert await asyncio.wait_for(abort, 1) == result + await asyncio.wait_for(second, 1) + assert len(server.inputs) == 2 + assert len(interrupts) == (1 if prerequisite == "interrupt" else 0) + finally: + release.set() + tasks = [task for task in (first, abort, second) if task is not None] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_idle_lru_retirement_allows_fresh_sessions_and_forbids_continuation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch) + server.hub.session_limit = 2 + try: + await server.engine.submit("ses_one", "one") + server.engine.ensure_capacity() + server.engine.register_created("ses_two", "/workspace") + await server.engine.submit("ses_two", "two") + # Touch one: two must be the LRU victim despite its later creation. + await server.engine.submit("ses_one", "again") + server.engine.ensure_capacity() + server.engine.register_created("ses_three", "/workspace") + await server.engine.submit("ses_three", "three") + assert set(server.engine.sessions) == {"ses_one", "ses_three"} + assert set(server.hub.sessions) == {"ses_one", "ses_three"} + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError, match="gateway-created"): + await server.engine.submit("ses_two", "retired") + assert len(server.requests) == count + for index in range(5): + server.engine.ensure_capacity() + session = f"ses_fresh{index}" + server.engine.register_created(session, "/workspace") + await server.engine.submit(session, "fresh") + assert len(server.engine.sessions) == len(server.hub.sessions) == 2 + finally: + await server.close() + + +@pytest.mark.asyncio +async def test_active_and_quarantined_capacity_fail_before_creation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + server.hub.session_limit = 1 + task = asyncio.create_task(server.engine.submit("ses_one", "active")) + try: + await server.admitted.wait() + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError, match="capacity"): + server.engine.ensure_capacity() + assert len(server.requests) == count + assert set(server.engine.sessions) == {"ses_one"} + task.cancel() + await asyncio.gather(task, return_exceptions=True) + with pytest.raises(V2ExecutionUncertainError, match="capacity"): + server.engine.ensure_capacity() + assert len(server.requests) == count + assert server.hub.sessions["ses_one"].error is not None + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_active_execution_budget_spans_settlement_not_http_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + server.engine.max_active = 1 + server.engine.register_created("ses_two", "/workspace") + first = asyncio.create_task(server.engine.submit("ses_one", "A")) + try: + await server.admitted.wait() + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError, match="Active execution capacity"): + await server.engine.submit("ses_two", "B") + assert len(server.requests) == count and len(server.inputs) == 1 + assert server.engine.sessions["ses_two"].subscription.error is None + server.execution("ses_one", server.inputs[0]) + await first + server.mode = "success" + result = await server.engine.submit("ses_two", "B") + assert result.outcome is not None and result.outcome.output == "ses_two" + assert len(server.inputs) == 2 and server.engine._active == 0 + finally: + first.cancel() + await asyncio.gather(first, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_uncertain_native_execution_does_not_release_a_quarantined_slot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + server.engine.max_active = 1 + server.engine.register_created("ses_two", "/workspace") + task = asyncio.create_task(server.engine.submit("ses_one", "A")) + try: + await server.admitted.wait() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError, match="Active execution capacity"): + await server.engine.submit("ses_two", "B") + assert len(server.requests) == count and len(server.inputs) == 1 + assert server.engine._active == 1 + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +async def test_intervention_guard_and_expected_abort_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = NativeServer(monkeypatch, "waiting") + task: asyncio.Task[object] | None = None + try: + with pytest.raises(V2ExecutionUncertainError, match="in-flight"): + server.engine.validate_intervention("ses_one") + assert server.requests == [] + task = asyncio.create_task(server.engine.submit("ses_one", "A")) + await server.admitted.wait() + assert server.engine.validate_intervention("ses_one") == server.inputs[0] + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError): + await server.engine.abort("ses_one", "msg_earlier_task") + assert len(server.requests) == count + server.engine.sessions["ses_one"].subscription.poison("test quarantine") + with pytest.raises(V2ExecutionUncertainError, match="test quarantine"): + server.engine.validate_intervention("ses_one") + assert len(server.requests) == count + finally: + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await server.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failed", [False, True]) +async def test_reasoning_and_tool_results_are_structured_before_model_continuation( + monkeypatch: pytest.MonkeyPatch, failed: bool +) -> None: + server = NativeServer(monkeypatch, "waiting") + task = asyncio.create_task(server.engine.submit("ses_one", "read", emit_updates=True)) + try: + await server.admitted.wait() + session = "ses_one" + assistant = "msg_step" + events: list[tuple[str, dict[str, object]]] = [ + ("session.inbox.enqueued", {"inboxID": server.inputs[0], "item": {"type": "user"}}), + ("session.inbox.delivered", {"inboxID": server.inputs[0]}), + ("session.step.started", {"assistantMessageID": assistant}), + ("session.reasoning.started", {"assistantMessageID": assistant, "ordinal": 0}), + ( + "session.reasoning.delta", + {"assistantMessageID": assistant, "ordinal": 0, "delta": "inspect"}, + ), + ( + "session.reasoning.ended", + {"assistantMessageID": assistant, "ordinal": 0, "text": "inspect"}, + ), + ( + "session.tool.input.started", + {"assistantMessageID": assistant, "id": "call_read", "name": "read"}, + ), + ( + "session.tool.called", + { + "assistantMessageID": assistant, + "id": "call_read", + "input": {"path": "example.txt"}, + "executed": True, + }, + ), + ( + "session.tool.progress", + { + "assistantMessageID": assistant, + "id": "call_read", + "metadata": {"title": "Reading"}, + }, + ), + ( + "session.tool.failed" if failed else "session.tool.success", + { + "assistantMessageID": assistant, + "id": "call_read", + "content": [ + {"type": "text", "text": "file contents"}, + { + "type": "file", + "uri": "file:///workspace/example.txt", + "mime": "text/plain", + }, + ], + "executed": True, + **({"error": {"type": "tool", "message": "read failure"}} if failed else {}), + }, + ), + ] + for kind, data in events: + server.feed.send(server.event(session, kind, **data)) + updates = [] + for _ in range(7): + raw = await asyncio.wait_for(server.engine.next_update(session), 1) + assert isinstance(raw, dict) + update = normalize_v1_event(raw) + assert update is not None + updates.append(update) + assert [update.part_type for update in updates[:3]] == ["reasoning", None, "reasoning"] + assert updates[1].delta == "inspect" and updates[2].snapshot == "inspect" + terminal = updates[-1].tool_payload + assert terminal is not None and terminal["tool"] == "read" + assert terminal["call_id"] == "call_read" + assert terminal["status"] == ("error" if failed else "completed") + assert terminal["output"] == [ + {"type": "text", "text": "file contents"}, + {"type": "file", "uri": "file:///workspace/example.txt", "mime": "text/plain"}, + ] + assert not task.done() # No model continuation or execution terminal was required. + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await server.close() diff --git a/tests/upstream/test_v2_observation_guards.py b/tests/upstream/test_v2_observation_guards.py new file mode 100644 index 00000000..5b41847e --- /dev/null +++ b/tests/upstream/test_v2_observation_guards.py @@ -0,0 +1,58 @@ +import asyncio + +import pytest + +from opencode_a2a.upstream.v2_events import V2ExecutionUncertainError +from tests.upstream.test_v2_execution import NativeServer + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scenario", ["duplicate-delta", "unary-intervention"]) +async def test_uncertain_progress_and_unsupported_intervention_never_complete( + monkeypatch: pytest.MonkeyPatch, scenario: str +) -> None: + server = NativeServer(monkeypatch, "waiting") + pending = asyncio.create_task(server.engine.submit("ses_one", "input", emit_updates=False)) + try: + await asyncio.wait_for(server.admitted.wait(), 1) + identity = server.inputs[0] + for event in [ + server.event( + "ses_one", "session.inbox.enqueued", inboxID=identity, item={"type": "user"} + ), + server.event("ses_one", "session.inbox.delivered", inboxID=identity), + server.event("ses_one", "session.step.started", assistantMessageID="msg_assistant"), + ]: + server.feed.send(event) + if scenario == "duplicate-delta": + delta = { + "id": "evt_repeated_delta", + "type": "session.text.delta", + "data": { + "sessionID": "ses_one", + "assistantMessageID": "msg_assistant", + "ordinal": 0, + "delta": "text", + }, + } + server.feed.send(delta) + server.feed.send(delta) + expected = "identity budget" + else: + server.feed.send( + { + "id": "evt_form", + "type": "form.created", + "data": {"form": {"sessionID": "ses_one", "id": "frm_question"}}, + } + ) + expected = "Unary interventions are unsupported" + with pytest.raises(V2ExecutionUncertainError, match=expected): + await asyncio.wait_for(pending, 1) + assert len(server.inputs) == 1 + assert server.engine.sessions["ses_one"].subscription.error is not None + assert server.engine._active == 1 + finally: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + await server.close() From a2c76f30b8e6c6e877186ea8f32a6e49d049cc27 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:01:49 +0100 Subject: [PATCH 11/30] feat(v2): enable guarded exclusive execution and interventions (#4, #5) --- README.md | 11 + SECURITY.md | 1 + docs/compatibility.md | 166 ++++++- docs/evidence/v2-exclusive-acceptance.json | 127 ++++++ docs/guide.md | 3 +- docs/security-architecture.md | 1 + docs/v2-exclusive-execution.md | 131 ++++++ src/opencode_a2a/config.py | 3 + .../contracts/extensions/capabilities.py | 41 +- .../contracts/extensions/compatibility.py | 121 ++++-- src/opencode_a2a/execution/coordinator.py | 23 +- src/opencode_a2a/execution/executor.py | 116 ++++- src/opencode_a2a/execution/session_manager.py | 2 +- src/opencode_a2a/execution/stream_runtime.py | 2 + src/opencode_a2a/jsonrpc/application.py | 4 +- src/opencode_a2a/server/agent_card.py | 41 +- src/opencode_a2a/server/application.py | 1 + src/opencode_a2a/server/middleware.py | 12 +- src/opencode_a2a/server/openapi.py | 29 ++ src/opencode_a2a/server/request_handler.py | 12 + src/opencode_a2a/upstream/v2.py | 237 +++++++++- src/opencode_a2a/upstream/v2_interventions.py | 224 ++++++++++ src/opencode_a2a/upstream/v2_profile.py | 59 +++ .../test_extension_contract_consistency.py | 10 + .../test_v2_exclusive_capabilities.py | 123 ++++++ .../execution/test_cancel_waiting_session.py | 54 +++ .../test_v2_executor_cancellation.py | 245 +++++++++++ tests/execution/test_v2_prompt_failfast.py | 8 +- .../test_jsonrpc_unsupported_method.py | 5 +- tests/server/test_v2_cancel_http_boundary.py | 204 +++++++++ tests/upstream/test_v2_interventions.py | 411 ++++++++++++++++++ tests/upstream/test_v2_profile.py | 46 ++ tests/upstream/test_v2_stream_metadata.py | 67 +++ tests/upstream/test_v2_upstream_adapter.py | 7 +- 34 files changed, 2443 insertions(+), 104 deletions(-) create mode 100644 docs/evidence/v2-exclusive-acceptance.json create mode 100644 docs/v2-exclusive-execution.md create mode 100644 src/opencode_a2a/upstream/v2_interventions.py create mode 100644 src/opencode_a2a/upstream/v2_profile.py create mode 100644 tests/contracts/test_v2_exclusive_capabilities.py create mode 100644 tests/execution/test_cancel_waiting_session.py create mode 100644 tests/execution/test_v2_executor_cancellation.py create mode 100644 tests/server/test_v2_cancel_http_boundary.py create mode 100644 tests/upstream/test_v2_interventions.py create mode 100644 tests/upstream/test_v2_profile.py create mode 100644 tests/upstream/test_v2_stream_metadata.py diff --git a/README.md b/README.md index 7b52ff57..4dcb6c92 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,17 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json ## Capabilities +The following capability list describes the default OpenCode API V1 mode. With +`OPENCODE_API_VERSION=v2`, only internal experimental session transport is +available: A2A message execution fails before upstream requests, streaming and +all extensions are disabled. A separate opt-in +[`OPENCODE_V2_EXECUTION=exclusive` profile](docs/v2-exclusive-execution.md) +adds restricted text execution and streaming read/question interventions in a +dedicated private instance pair. It does not support shared upstream sessions, +arbitrary tools, model overrides, or automatic recovery after event loss. Read +the deployment preconditions and [compatibility matrix](docs/compatibility.md#runtime-support) +before enabling it. + - A2A HTTP+JSON endpoints such as `/message:send` and `/message:stream` - A2A JSON-RPC support on `POST /` - SDK-owned A2A task surfaces such as `GET /tasks`, task push notification config routes, JSON-RPC `GetExtendedAgentCard`, and HTTP `GET /extendedAgentCard` diff --git a/SECURITY.md b/SECURITY.md index 1b4217bb..555a3d71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,7 @@ This repository wraps OpenCode as an A2A runtime. It exposes A2A HTTP+JSON and J - LLM provider keys are consumed by the `opencode` process. Prompt injection or indirect exfiltration attempts may still expose sensitive values. - Payload logging is opt-in. When `A2A_LOG_PAYLOADS=true`, operators should treat logs as potentially sensitive operational data. - This project does not ship host bootstrap or process-manager wrappers as an official product capability. Operators remain responsible for file permissions, secret storage, service users, and supervisor-specific hardening. +- Experimental V2 exclusive execution additionally requires a dedicated private network namespace, unshared upstream credentials, one gateway process, and no autonomous session writers. Loopback URL validation is not topology verification. See [the restricted profile](docs/v2-exclusive-execution.md) before enabling it; shared everyday OpenCode services are unsupported. - Release artifacts are built in a read-only job, audited, and checksummed before separate least-privilege PyPI and GitHub Release jobs can upload them. Releases include a CycloneDX runtime SBOM. ## Threat Model diff --git a/docs/compatibility.md b/docs/compatibility.md index 2becf290..2bb961ef 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -8,13 +8,23 @@ This document defines the compatibility promises `opencode-a2a` currently uphold - A2A Python SDK: `a2a-sdk==1.2.3` (SDK package version; not an OpenCode API version) - Supported A2A protocol line: `1.0` - OpenCode runtime line: `1.18.x` (verified with `1.18.19`) +- Experimental exclusive execution profile: OpenCode `2.0.28` only; see the + [deployment contract](./v2-exclusive-execution.md). -OpenCode V2 is an opt-in, experimental session-transport slice selected with +By default, OpenCode V2 is an opt-in, experimental session-transport slice selected with `OPENCODE_API_VERSION=v2`. It implements `/api/info`, `/api/location`, and session create/get/list transport only; A2A prompt execution and all OpenCode-specific JSON-RPC extensions remain unavailable. This remains an explicit safety gate, not a claim that V2 prompt execution works. +The additional selector `OPENCODE_V2_EXECUTION=exclusive` enables a separately +verified restricted profile: dedicated private upstream, gateway-created +single-flight sessions, text execution, and streaming read/question +interventions. It does not remove the default gate or solve general shared-session +correlation. The following legacy/default-V2 discussion describes selection +**without** that explicit profile; its earlier general correlation NO-GO remains +valid. See the scoped acceptance matrix below. + The V2 selector is **OpenCode's upstream HTTP API version**, not "A2A SDK V2". The runtime continues to expose A2A protocol 1.0 interfaces. Its V2 Agent Card contains no extension declarations and retains only an informational execution @@ -42,15 +52,155 @@ HTTP request. The same extension hiding applies to shared interrupt callbacks. V2 support does **not** claim A2A execution, task-scoped cancellation, permission/form interventions, request-scoped model overrides, structured tool execution, or outbound `a2a_call` support. `2.0.28` evidence is limited to -session transport; no V2 model-backed acceptance run is claimed. +session transport plus an upstream-only correlation probe; no V2 model-backed +A2A acceptance run is claimed. Parent epic +#1 and the original unrestricted #4/#5 acceptance criteria remain open. The +exclusive profile's narrower evidence does not silently close those criteria. + +### Exclusive-profile acceptance matrix + +Evidence is recorded in [the sanitized acceptance record](./evidence/v2-exclusive-acceptance.json). +`PASS` applies only to the described tested subset; `NOT VERIFIED` is not a +support claim. V1 remains the stable default. Upstream review/merge and the +epic's final scope decision are separate from these execution results. + +| Capability | V1 | V2 exclusive mode | Evidence / limitation | +| --- | --- | --- | --- | +| Session creation | PASS | PASS | Real pinned servers; V2 session-only smoke and model admission | +| A2A streaming | PASS | PASS | Real V1 JSON-RPC; real V2 REST and JSON-RPC, assistant artifacts only | +| A2A non-streaming | PASS | PASS | Real model, terminal state and final artifacts rather than admission | +| Follow-up | PASS | PASS | Remembered nonce; V2 requires a healthy live gateway session | +| Concurrent sessions | NOT VERIFIED | PASS | Separate V2 contexts run without output mixing | +| Shared-session concurrency | NOT VERIFIED | UNSUPPORTED | V1 risk #8 remains; V2 serializes gateway requests and excludes other writers | +| Cancellation | PASS | PASS | V1 durable canceled state; V2 observed active interruption and local queued cancellation | +| Permissions | PASS | PASS | V1 bash allow/reject; V2 read once/reject only, no blanket or persistent grant | +| Forms / questions | PASS | PASS | V1 question reply; V2 question string/multiselect and invalid/stale/cross-owner rejection | +| Request-scoped model selection | NOT VERIFIED | UNSUPPORTED | V2 backend model configured explicitly; overrides rejected before upstream calls | +| `a2a_call` | NOT VERIFIED | UNSUPPORTED | V2 tool policy and discovery exclude it | +| Disconnect recovery | NOT VERIFIED | UNSUPPORTED | V2 gap/terminal loss yields failure and quarantine, not replay or successful recovery | +| Lost-event / ambiguous-admission safety | NOT VERIFIED | PASS | Real upstream plus transport fault injection; one admission, no false success or resend | +| Unary interventions / rich forms | NOT VERIFIED | UNSUPPORTED | Explicit rejection/quarantine; only streaming supported question fields are exposed | +| Reasoning blocks | NOT VERIFIED | NOT VERIFIED | Deterministic native mapping tests pass; accepted live run observed text/tool blocks only | + +V1 cancellation remains an A2A terminal-state contract with best-effort upstream +abort; it is not a rollback or exactly-once side-effect guarantee. V2 native +pending-inbox deletion is not independently advertised as successful cancellation: +without terminal proof it fails uncertain and retains quarantine. Restart, +retired-context continuation, and transparent upstream feed reconnection are not +supported by the exclusive profile. See [reproduction commands and detailed +preconditions](./v2-exclusive-execution.md#reproduce-the-live-evidence). + +The pinned OpenCode `2.0.28` source confirms one useful correlation link: a +caller-supplied `Session.Message.ID` is reconciled as the inbox ID, and the +`InboxDelivered` projector writes the visible `Session.Message.User` with that +same ID. That identifies admission and delivery, but does not directly bind +later assistant steps or session execution terminals to the input. Reusing the +same ID in the same session/type reconciles to the first admission, including +after delivery; a retry must therefore reuse the exact original payload and ID. + +```text +POST prompt(id) -> inbox.enqueued(inboxID=id) -> inbox.delivered(inboxID=id) + -> projected user message(id) [proven] + -> step.started(assistantMessageID) + -> step.ended(assistantMessageID) + -> execution.terminal(sessionID) [not input-scoped] +``` + +`Step.Started` / `Step.Ended` carry an assistant message ID, not the input ID; +`session.execution.*` carries only the session ID and represents a process-local +busy period. The runner delivers inputs at step boundaries. At an input +boundary, it prioritizes pending steers, delivers at most one queued input, then +also promotes steers that arrived during delivery. Thus `delivery=queue` and +the gateway's local session lock do not exclude another OpenCode client from +the same step context. A conservative live observer could infer a step belongs +to an input only while that input is the sole newly delivered prompt in the +step's context, then fail uncertain on interleaving. That inference is not +implemented or verified here. + +Recovery is a separate blocker. The experimental +`GET /api/experimental/session/{sessionID}/log?after=...&follow=true` uses an +exclusive aggregate sequence and a replay-to-live `log.synced` watermark, but +v2.0.28 stores event rows only when the internal `events.persist` option is +enabled. The stock CLI does not set that option and the bus default is false; +the log therefore cannot replay durable step events in the default server +configuration. Persisted rows have no configured TTL and are deleted with the +session. `GET /api/event` is a separate volatile feed that misses events during +disconnection and has no replay cursor. Message projections can show current +assistant snapshots, but do not expose the durable event sequence needed to +reconstruct a missed input-to-step boundary with certainty. + +Cancellation is also not task-scoped: `DELETE .../inbox/{inboxID}` only cancels +an undelivered item and returns no indication whether it was available; +`POST .../interrupt` interrupts active execution for the whole session. The +adapter cannot safely use that interrupt for one A2A task when another client +may have queued work. The adapter therefore rejects before creating a session +or submitting a prompt rather than risking cross-task completion, false +cancellation, or duplicate admission after an uncertain timeout. + +Evidence links: pinned [prompt implementation](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/session.ts#L145-L175), [inbox reconciliation and delivery](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/inbox.ts#L154-L201), [delivery ordering](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/inbox.ts#L491-L529), [input projector](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/projector.ts#L618-L648), [step and execution event schemas](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/schema/src/session-event.ts#L203-L370), [assistant and idle message schemas](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/schema/src/session-message.ts#L212-L295), [bus persistence and log replay](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/bus.ts#L174-L204), and [stock CLI server options](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/cli/src/server-process.ts#L94-L134). The generated [v2.0.28 OpenAPI](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/protocol/openapi.json) specifies the endpoint envelopes. A v2.0.30 source spot-check found the same prompt-ID reconciliation, no input ID in assistant/step/terminal schemas, and the same `events.persist=false` bus default. + +### Live upstream Phase 1 correlation probe + +A sanitized, isolated upstream-only experiment against OpenCode `2.0.28` used +`openai/gpt-6-luna-fast`, a temporary workspace, and restrictive session +permissions. It did **not** use the A2A adapter; the API credential was passed +only to the isolated server through a mode-restricted temporary file and was +neither printed nor included in prompt text. The event subscription returned +HTTP 200 before prompt admission. Two distinct `delivery=queue` inputs were +admitted; the second was submitted after the first `session.step.started`. Both +receipt IDs matched the caller-supplied IDs, both IDs appeared in +`session.inbox.delivered` and the projected user messages, and separate +assistant messages were projected. Both expected sentinel strings appeared +across the assistant history; this probe did not retain raw assistant text or +prove which individual message contained which sentinel. The observed order +was: + +```text +inbox.enqueued(A) -> inbox.delivered(A) -> step.started(S1) +-> inbox.enqueued(B) -> step.ended(S1) -> inbox.delivered(B) +-> step.started(S2) -> step.ended(S2) +-> session.execution.succeeded(session only) +``` + +`S1` and `S2` denote the assistant message IDs carried by step events; the +events do not identify either step as caused by input A or B. +The single terminal event followed both prompts and contained no input ID; +assistant message schemas also had no input-link field. A separate pending +input test confirmed exact same-ID retry returned the original receipt and +deleting that still-undelivered inbox item returned `204`. After closing the +volatile feed, the experimental session log returned HTTP 200 with only a +`log.synced` frame and no replayed step/input event. This demonstrates one +controlled queue ordering, **not** a contract that every input has a distinct +assistant or terminal result, nor a completed A2A execution E2E. +The feed was closed after the observed terminal event; a mid-execution +disconnect/recovery scenario was not exercised. + +A property-whitelisted, sanitized event trace and boolean run assertions are +recorded in +[`docs/evidence/opencode-v2.0.28-correlation-probe.json`](./evidence/opencode-v2.0.28-correlation-probe.json). +It omits credentials, session/workspace IDs, prompt and model text, sentinel +strings, and raw event payloads. It documents one observed run, not a general +upstream guarantee. + +**Phase 1 decision: FAIL.** Do not enable V2 A2A prompts from this evidence. +The smallest feasible upstream remedy is to propagate a stable input/execution +ID through assistant-step and terminal events, provide replayable or +input-addressable terminal status, and make active cancellation input-scoped +(or document an enforceable exclusive-session contract). Until then, ambiguous +or disconnected outcomes must remain fail-closed. V1 stays the default, and the adapter does not auto-detect or fall back between -versions. The V2 session transport was exercised against OpenCode `2.0.28`; that -experimental check does not add V2 to the supported runtime line above. -OpenCode V2 allows `external_directory` by default. Before any future release of -V2 prompt execution, operators must configure an explicit restrictive upstream -V2 `permissions` policy. The current session-only slice does not perform model -execution. +versions. Reproduce the V2 session-transport check with +`bash ./scripts/live_opencode_v2_session_smoke.sh`. It is pinned to +OpenCode `2.0.28`, verifies Basic-auth rejection plus `/api/info`, `/api/location`, +and session create/get/list through the application-selected V2 adapter, and +submits no model prompt. This smoke does not add V2 to the supported runtime +line above. V2 +session model selection changes persistent session state, so request-scoped +model overrides are not supported; V2 `a2a_call` execution parity is also not +claimed. OpenCode V2 allows `external_directory` by default. Before any future +release of V2 prompt execution, operators must configure an explicit +restrictive upstream V2 `permissions` policy. This is the single canonical place where the supported upstream OpenCode version line is declared. Keep it updated together with the live integration smoke check (`./scripts/live_opencode_smoke.sh`) and do not duplicate the version line elsewhere. diff --git a/docs/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json new file mode 100644 index 00000000..4937ede8 --- /dev/null +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -0,0 +1,127 @@ +{ + "verification_date": "2026-10-25", + "verified_at": "2026-10-25T17:58:12+01:00", + "source_tree_sha256": "c1c629a9a514112e7a7b3716f840195bde4a6b269c09b820980b07ab0f15c766", + "source_hash_method": "Sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", + "scope": "Experimental private, gateway-created, single-flight sessions only; not general OpenCode V2 compatibility", + "versions": { + "opencode_v1": "1.18.19", + "opencode_v2": "2.0.28", + "a2a_sdk": "1.2.3", + "observed_v2_model": "openai/gpt-6-luna-fast" + }, + "operator_boundary": { + "upstream_loopback_inside_disposable_network_namespace": "PASS", + "authenticated_readiness_before_network_denial_checks": "PASS", + "host_and_independent_container_cannot_reach_upstream_listener": "PASS", + "missing_and_wrong_upstream_credentials_rejected": "PASS", + "topology_automatically_certified_by_adapter": false, + "fresh_private_state_and_no_external_writers_required": true, + "protected_configuration_and_one_gateway_process_required": true + }, + "v2_native_single_flight": { + "known_input_admission_and_delivery": "PASS", + "server_connected_before_admission": "PASS", + "ordered_assistant_step_and_later_durable_terminal": "PASS", + "exact_observed_assistant_projection": "PASS", + "sequential_followup_retains_context": "PASS", + "parallel_distinct_sessions": "PASS", + "question_form_answer_and_active_interruption": "PASS" + }, + "v2_a2a_live": { + "rest_streaming": "PASS", + "rest_unary": "PASS", + "jsonrpc_streaming": "PASS", + "jsonrpc_unary": "PASS", + "assistant_artifact_only_output_assertions": "PASS", + "append_and_snapshot_reconstruction": "PASS", + "persisted_terminal_task_and_sequential_context": "PASS", + "parallel_distinct_sessions": "PASS", + "same_session_serialization": "PASS", + "local_queued_cancel_without_owner_interrupt": "PASS", + "active_cancel_from_observed_native_interruption": "PASS", + "read_permission_once_and_reject": "PASS", + "string_and_multiselect_question_answers": "PASS", + "invalid_typed_answer_rejected_before_native_reply": "PASS", + "cross_owner_task_and_callback_rejected": "PASS", + "stale_callback_rejected": "PASS", + "observed_block_types": ["text", "tool_call"], + "reasoning_blocks_live": "NOT VERIFIED" + }, + "v2_live_http_counts": { + "prompt_admissions": 13, + "global_event_connections": 1, + "session_creations": 11, + "profile_config_reads": 24, + "plugin_inventory_reads": 24, + "persisted_permission_checks": 13, + "active_and_inbox_checks_each": 25, + "foreground_wait_and_message_projection_reads_each": 12, + "form_replies": 3, + "permission_replies": 2, + "active_interrupts": 1, + "normal_path_periodic_polling": false + }, + "v2_real_upstream_negative_runs": [ + {"scenario": "mid_execution_sse_disconnect", "result": "PASS", "admissions": 1, "event_connections": 1, "false_success": false, "resubmission": false}, + {"scenario": "dropped_terminal_event", "result": "PASS", "admissions": 1, "event_connections": 1, "false_success": false, "resubmission": false}, + {"scenario": "lost_admission_response", "result": "PASS", "admissions": 1, "event_connections": 1, "false_success": false, "resubmission": false}, + {"scenario": "real_provider_rejects_synthetic_invalid_credential", "result": "PASS", "native_terminal": "session.execution.failed", "admissions": 1, "false_success": false}, + {"scenario": "sse_disconnect_while_input_required", "result": "PASS", "admissions": 1, "native_late_replies": 0, "false_success": false}, + {"scenario": "expired_intervention", "result": "PASS", "admissions": 1, "native_late_replies": 0, "false_success": false} + ], + "deterministic_adversarial_coverage": [ + "Delayed interrupt and pending-delete races cannot reach a successor invocation", + "Real ASGI/SDK cancellation settlement preserves natural completion instead of FAILED", + "Duplicate transient and durable events fail closed", + "Poisoned or ended leases cannot authorize callbacks", + "Concurrent callbacks issue one native mutation", + "Readiness timeout closes reader and releases stream capacity", + "Unknown sessions and weakened permissions reject before prompt admission", + "Idle registry retirement and active/quarantined execution budgets remain bounded" + ], + "unsupported": [ + "External or unrestricted shared-session writers", + "Automatic replay or continuation after upstream observation loss", + "Adoption of unknown, post-restart, or retired sessions", + "Unary interventions and generic/rich forms", + "Blanket or persistent permission approval", + "Attachments, request-scoped models, arbitrary tools, and a2a_call", + "Claiming native pending deletion succeeded without terminal proof" + ], + "v1_expanded_matrix": { + "checks_passed": 12, + "checks_failed": 0, + "checks_not_verified": 0, + "positive_checks": 7, + "negative_auth_ownership_checks": 5, + "assistant_artifact_only_assertions": true, + "artifact_append_and_snapshot_reconstruction": true, + "permission_allow_side_effect_exact_file_content": "PASS", + "permission_reject_native_tool_artifact_and_absent_file": "PASS", + "stream_unary_followup_question_auth_ownership": "PASS", + "cancellation_trials_passed": 6, + "pending_question_trials": 3, + "approved_long_running_shell_trials": 3, + "additional_observation_seconds": 46, + "late_canceled_to_failed_or_completed_transitions": 0, + "persisted_canceled_state": "PASS", + "native_abort_successes": 6, + "native_abort_timeouts_or_errors": 0, + "reproduction_note": "Independent disposable manual runner; repository live_opencode_smoke.sh separately passed with assistant-artifact assertions. No model output or runtime credentials retained here." + }, + "deterministic_validation": { + "doctor_command": "bash ./scripts/doctor.sh", + "tests_passed": 1011, + "tests_failed": 0, + "line_coverage_percent": 92.67, + "critical_file_coverage_gates": "PASS", + "typing_lint_format_build_wheel_smoke": "PASS", + "conformance_passed": 8, + "conformance_failed": 0, + "default_v2_session_smoke": "PASS", + "v1_repository_streaming_smoke": "PASS" + }, + "reproduction": "docs/v2-exclusive-execution.md#reproduce-the-live-evidence", + "redactions": ["Credentials", "Raw prompts and model output", "Opaque session/task/request IDs", "Runtime paths and PIDs"] +} diff --git a/docs/guide.md b/docs/guide.md index b6c158ae..fc6cf635 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -29,7 +29,8 @@ Key variables to understand protocol behavior: - `A2A_STATIC_AUTH_CREDENTIALS`: required static credential registry in JSON array form. Supports multiple `bearer` / `basic` credentials, bearer-only required `principal`, optional `credential_id`, optional `capabilities`, and optional `enabled=false` for explicit disablement. - `OPENCODE_BASE_URL`: upstream OpenCode HTTP endpoint. Default: `http://127.0.0.1:4096`. In two-process deployments, set it explicitly. -- `OPENCODE_API_VERSION`: OpenCode upstream API selector, `v1` (default) or `v2`; this is unrelated to the `a2a-sdk` package version. Selection is explicit, with no auto-detection or fallback. V2 currently provides internal session transport only, not A2A execution. The A2A 1.0 interfaces remain on the card; the V2 status skill is informational, and SDK-required non-empty default input/output modes do not promise that messages are executable. `SendMessage` returns a failed task before session creation or prompt HTTP; `SendStreamingMessage` is rejected because V2 declares `streaming=false`. Direct extension calls return `-32601 Method not found` before upstream HTTP. OpenCode-specific and shared extension declarations remain hidden. See [compatibility](./compatibility.md#runtime-support) for pinned evidence and limitations. +- `OPENCODE_API_VERSION`: upstream API selector, `v1` (default) or `v2`, unrelated to the A2A SDK version. No auto-detection or fallback. V2 alone retains session-only transport and fails A2A execution before upstream HTTP. +- `OPENCODE_V2_EXECUTION`: `disabled` (default) or `exclusive`. The latter opts into the [restricted V2 profile](./v2-exclusive-execution.md), requires a dedicated private single-process instance pair with protected configuration and durable ownership, and does not enable general shared-session support. Read its security preconditions before selecting it. - `OPENCODE_WORKSPACE_ROOT`: service-level default workspace root exposed to OpenCode when clients do not request a narrower directory override. - `A2A_ALLOW_DIRECTORY_OVERRIDE`: controls whether clients may pass `metadata.opencode.directory`. - `A2A_ENABLE_SESSION_SHELL`: gates high-risk JSON-RPC method `opencode.sessions.shell`. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 0bf5a8a8..1a234824 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -169,6 +169,7 @@ skipped idempotently. | R-5 | Payload logging can capture sensitive data when enabled | Accepted | Opt-in with `A2A_LOG_BODY_LIMIT` cap; treat logs as sensitive. [SECURITY.md](../SECURITY.md) | | R-6 | Push notification config surface exposed-but-unsupported | Accepted | A2A 1.0-specific errors are stable (REST 400 / JSON-RPC -32003); capability recovery is intentionally deferred | | R-7 | SQLite hardening exempts `:memory:` / `file:` URIs and non-POSIX platforms | Accepted | Plain absolute file path recommended for deployments. [guide.md](./guide.md) "SQLite Persistence Hardening" | +| R-8 | V2 terminals are session-scoped, so shared or autonomous writers invalidate task attribution | Restricted profile only | Dedicated private network namespace, separate credentials, one gateway process, gateway-created sessions, restrictive tools/configuration, retained leases and quarantine. See [V2 exclusive execution](./v2-exclusive-execution.md). The adapter cannot certify operator network isolation. | ## Maintenance Rules diff --git a/docs/v2-exclusive-execution.md b/docs/v2-exclusive-execution.md new file mode 100644 index 00000000..5b2a4eb5 --- /dev/null +++ b/docs/v2-exclusive-execution.md @@ -0,0 +1,131 @@ +# Experimental V2 Exclusive Execution + +This is a restricted, opt-in execution profile for the pinned upstream version +in [compatibility.md](./compatibility.md#runtime-support), not general V2 +shared-session support. V1 remains the default. Selecting only +`OPENCODE_API_VERSION=v2` still disables A2A execution. + +## Required deployment boundary + +Set `OPENCODE_V2_EXECUTION=exclusive` **only** for a dedicated instance pair: + +- Run exactly one gateway process and one upstream process with fresh private + upstream state. Do not use an everyday CLI/TUI background service. +- Put both processes in a dedicated network namespace. Bind upstream to + `127.0.0.1`; publish only the gateway's A2A listener, never upstream's port. + Loopback on the ordinary shared host is not network isolation. +- Use separate random upstream Basic credentials, available only to this pair. + Do not share pairing tokens, cookies, authenticated URLs, provider credentials, + runtime storage, or container administration access with A2A consumers. +- Protect configuration and workspace configuration/plugin discovery paths from + other writers. No custom plugins, MCP servers, warming, background subagents, + or external schedulers. The adapter checks effective configuration and plugin + inventory; these checks are not a replacement for filesystem/network controls. +- Use `A2A_TASK_STORE_BACKEND=database` and a protected SQLite database. Do not + run multiple gateway workers or share upstream storage between server processes. + +The adapter enforces a loopback URL, upstream credentials, explicit workspace, +database-backed ownership, the exact upstream pin, and restrictive session +permissions. It cannot verify network topology or defend against a privileged +host/container administrator. Selecting `exclusive` acknowledges the operator +preconditions above; a Python lock alone never establishes them. + +The initial tool policy denies `*`, allows `question`, asks for `read`, and +explicitly denies `external_directory`. Shell, editing, subagents, outbound +`a2a_call`, and arbitrary tools are unavailable. Do not weaken this policy to +enable unreviewed producers. This remains a single-tenant shared-workspace +boundary, not hostile-tenant filesystem isolation. + +Pinned upstream applies session rules after agent rules and filters both model +definitions and dispatch entrypoints in +[session context](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/context.ts#L127-L139) +and the [tool registry](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/tool.ts#L225-L295). +The wildcard deny therefore also excludes Code Mode `execute` and its direct +HTTP `fetch`, session movement, and privileged builtin namespace tools even +when their leaf implementations omit a permission assertion. The adapter +rechecks the exact persisted session policy before admission. Fresh upstream +state is necessary: previously saved grants are not trusted by this profile. + +## Execution and failure semantics + +Only sessions created by this running gateway are executable. Unknown, +preexisting, imported, child, or post-restart session IDs cannot be adopted. +Persistent task history remains queryable after restart, but execution requires +a new context/session. Follow-up in a healthy live session retains context. + +Same-session requests serialize; different sessions may run concurrently. +One bounded native `/api/event` connection serves the instance. Subscription +readiness requires `server.connected`. Admission uses a fresh input ID exactly +once. Success requires observed own enqueue/delivery, sequenced assistant steps, +a later terminal, native foreground settlement, empty inbox, and matching +persisted user/assistant/idle projections. Text completion or inactivity alone +cannot complete a task. + +Disconnect, overflow, unknown work, ambiguous HTTP admission, missing terminal, +or uncertain cancellation fails without claiming success or rollback. The +session and its uncertain execution capacity remain quarantined; there is no +automatic retry, replay database, or polling-based transcript recovery. A dead +global feed fails the instance closed until it is replaced with a fresh pair. +The experimental session log is deliberately not used. + +The HTTP request limit also bounds active V2 execution through a separate +admission budget. The stream limit accounts for the shared connection. Observer +state is bounded; healthy idle sessions can be retired in LRU order at capacity. +Retired contexts must start a new session rather than adopt an old ID. + +Cancellation distinguishes local waiting tasks from admitted native work: + +- Local queued cancellation submits nothing and does not interrupt its owner. +- Active interrupt is session-wide, bound to the original task/input, and blocks + successor admission until the interrupt operation settles. Only observed + interrupted settlement is reported as canceled. Natural completion wins its + race truthfully; an uncertain interrupt is failed, not canceled. +- Native pending-inbox deletion alone is not a cancellation proof. If it cannot + be reconciled with a terminal, the session is quarantined. + +## Interventions and unsupported operations + +Streaming tasks support read approval (`once` or `reject`) and question-generated +forms with validated string/multiselect answers. Callbacks use existing +task/session/principal-bound tracking, expiry, and native 204 replies. Duplicate, +cross-owner, expired, unsupported, and post-observation-loss replies reject +before mutation. Callback eligibility is separate from native resolution events, +so a successful answer can still produce the working transition. + +Unary text requests await the same proven execution outcome. **Unary +interventions are unsupported**: an unexpected intervention fails and quarantines +the session without approval. Rich/generic form kinds, `always` permission grants, +attachments, request-scoped models, arbitrary session mutations, and `a2a_call` +are not advertised or implemented in this profile. General shared-session +execution and transparent reconnect/restart recovery remain unsupported. + +## Reproduce the live evidence + +Requires Docker, a local pinned OpenCode binary, `uv`, a real configured OpenAI +credential in the environment, and the stated local image. Credentials are +loaded through private files, never printed or passed as positional arguments. +The harness creates only disposable containers, workspaces, and credentials; +it does not contact or stop the preexisting OpenCode service. + +```bash +# Native isolated single-flight/continuation/intervention proof: +bash scripts/live_opencode_v2_exclusive_probe.sh + +# Real gateway REST/JSON-RPC, ownership, interventions, cancellation, concurrency: +V2_PROBE_IMAGE=mcr.microsoft.com/playwright:v1.59.1-noble \ +V2_PROBE_A2A=1 bash scripts/live_opencode_v2_exclusive_probe.sh + +# Independent fresh pair for each negative scenario: +for scenario in gap lost-terminal lost-admission provider-failure intervention-gap expired-intervention; do + V2_PROBE_IMAGE=mcr.microsoft.com/playwright:v1.59.1-noble \ + V2_PROBE_A2A=1 V2_PROBE_NATIVE=0 V2_PROBE_CASE="$scenario" \ + bash scripts/live_opencode_v2_exclusive_probe.sh +done +``` + +`V2_PROBE_NATIVE=0` skips only the separate native proof, not the A2A model call. +Provider-failure uses a synthetic invalid credential, not a fake provider. +Fault injection changes transport delivery while upstream execution is real. +The test-only proxy/control endpoint is not a shipped server capability. +Sanitized route counts and pass/fail assertions appear on stdout; model text, +credentials, raw events, workspace paths, and opaque IDs are not evidence output. diff --git a/src/opencode_a2a/config.py b/src/opencode_a2a/config.py index ea746cb4..9c39ccaf 100644 --- a/src/opencode_a2a/config.py +++ b/src/opencode_a2a/config.py @@ -150,6 +150,9 @@ class Settings(BaseSettings): # OpenCode settings opencode_api_version: OpencodeApiVersion = Field(default="v1", alias="OPENCODE_API_VERSION") + opencode_v2_execution: Literal["disabled", "exclusive"] = Field( + default="disabled", alias="OPENCODE_V2_EXECUTION" + ) opencode_base_url: str = Field(default="http://127.0.0.1:4096", alias="OPENCODE_BASE_URL") opencode_workspace_root: str | None = Field(default=None, alias="OPENCODE_WORKSPACE_ROOT") opencode_agent: str | None = Field(default=None, alias="OPENCODE_AGENT") diff --git a/src/opencode_a2a/contracts/extensions/capabilities.py b/src/opencode_a2a/contracts/extensions/capabilities.py index f906b9d4..b3819e04 100644 --- a/src/opencode_a2a/contracts/extensions/capabilities.py +++ b/src/opencode_a2a/contracts/extensions/capabilities.py @@ -72,24 +72,42 @@ def disabled_wire_contract_entry(self) -> dict[str, str] | None: class JsonRpcCapabilitySnapshot: upstream_api_version: OpenCodeApiVersion conditional_methods: Mapping[str, DeploymentConditionalMethod] + v2_exclusive_execution: bool = False @property def extensions_enabled(self) -> bool: - return self.upstream_api_version == "v1" + return self.upstream_api_version == "v1" or self.v2_exclusive_execution @property def a2a_message_execution_enabled(self) -> bool: - return self.upstream_api_version == "v1" + return self.upstream_api_version == "v1" or self.v2_exclusive_execution @property def streaming_enabled(self) -> bool: - return self.upstream_api_version == "v1" + return self.a2a_message_execution_enabled @property def declared_extension_uris(self) -> tuple[str, ...]: + if self.v2_exclusive_execution: + return ( + identifiers.SESSION_BINDING_EXTENSION_URI, + identifiers.STREAMING_EXTENSION_URI, + identifiers.INTERRUPT_RECOVERY_EXTENSION_URI, + identifiers.INTERRUPT_CALLBACK_EXTENSION_URI, + ) return DECLARED_EXTENSION_URIS if self.extensions_enabled else () + def supports_extension(self, uri: str) -> bool: + if self.upstream_api_version == "v1": + return True + return uri in self.declared_extension_uris + def is_method_enabled(self, method: str) -> bool: + if self.v2_exclusive_execution and method in _ALL_EXTENSION_METHODS: + return method in { + *catalog.INTERRUPT_RECOVERY_METHODS.values(), + *catalog.INTERRUPT_CALLBACK_METHODS.values(), + } if method in _ALL_EXTENSION_METHODS and not self.extensions_enabled: return False conditional_method = self.conditional_methods.get(method) @@ -98,7 +116,7 @@ def is_method_enabled(self, method: str) -> bool: return conditional_method.enabled def session_management_methods(self) -> dict[str, str]: - if not self.extensions_enabled: + if not self.extensions_enabled or self.v2_exclusive_execution: return {} methods = dict(catalog.SESSION_METHODS) if not self.is_method_enabled(catalog.SESSION_METHODS["shell"]): @@ -108,7 +126,7 @@ def session_management_methods(self) -> dict[str, str]: def dispatch_method_bindings(self) -> dict[str, str]: """Return handler registry bindings; every call is still checked against this policy.""" methods = self.session_management_methods() - if not self.extensions_enabled: + if not self.extensions_enabled or self.v2_exclusive_execution: methods = dict(catalog.SESSION_METHODS) if not self._deployment_method_enabled(catalog.SESSION_METHODS["shell"]): methods.pop("shell", None) @@ -130,7 +148,7 @@ def dispatch_workspace_control_methods(self) -> dict[str, str]: return methods def session_control_methods(self) -> dict[str, str]: - if not self.extensions_enabled: + if not self.extensions_enabled or self.v2_exclusive_execution: return {} methods = dict(catalog.SESSION_CONTROL_METHODS) if not self.is_method_enabled(catalog.SESSION_CONTROL_METHODS["shell"]): @@ -138,12 +156,14 @@ def session_control_methods(self) -> dict[str, str]: return methods def workspace_control_methods(self) -> dict[str, str]: - if not self.extensions_enabled: + if not self.extensions_enabled or self.v2_exclusive_execution: return {} return self.dispatch_workspace_control_methods() def supported_jsonrpc_methods(self) -> list[str]: methods = list(catalog.CORE_JSONRPC_METHODS) + if self.v2_exclusive_execution: + return [*methods, *self.extension_jsonrpc_methods()] if not self.extensions_enabled: return methods methods.extend(method for key, method in catalog.SESSION_METHODS.items() if key != "shell") @@ -156,6 +176,11 @@ def supported_jsonrpc_methods(self) -> list[str]: return methods def extension_jsonrpc_methods(self) -> list[str]: + if self.v2_exclusive_execution: + return [ + *catalog.INTERRUPT_RECOVERY_METHODS.values(), + *catalog.INTERRUPT_CALLBACK_METHODS.values(), + ] if not self.extensions_enabled: return [] methods = [ @@ -200,6 +225,7 @@ def build_capability_snapshot( *, runtime_profile: RuntimeProfile, upstream_api_version: OpenCodeApiVersion = "v1", + v2_exclusive_execution: bool = False, ) -> JsonRpcCapabilitySnapshot: conditional_methods = { catalog.SESSION_CONTROL_METHODS["shell"]: DeploymentConditionalMethod( @@ -223,4 +249,5 @@ def build_capability_snapshot( return JsonRpcCapabilitySnapshot( upstream_api_version=upstream_api_version, conditional_methods=MappingProxyType(conditional_methods), + v2_exclusive_execution=upstream_api_version == "v2" and v2_exclusive_execution, ) diff --git a/src/opencode_a2a/contracts/extensions/compatibility.py b/src/opencode_a2a/contracts/extensions/compatibility.py index 5a45ba9f..9a15fa80 100644 --- a/src/opencode_a2a/contracts/extensions/compatibility.py +++ b/src/opencode_a2a/contracts/extensions/compatibility.py @@ -244,6 +244,78 @@ def build_compatibility_profile_params( upstream_api_version=upstream_api_version, ) service_behaviors = build_service_behavior_contract_params() + consumer_guidance = [ + "Treat core A2A methods as the stable interoperability baseline for generic clients.", + ( + "Treat this deployment as a single-tenant, shared-workspace coding profile; " + "do not assume per-consumer workspace or tenant isolation." + ), + ] + if capability_snapshot.extensions_enabled: + consumer_guidance.extend( + [ + ( + "Treat shared model selection metadata as a stable request-scoped plugin " + "surface for the main chat path; provider defaults still belong to OpenCode." + ), + ( + "Treat opencode.sessions.*, opencode.providers.*, opencode.models.*, " + "opencode.projects.*, opencode.workspaces.*, opencode.worktrees.*, " + "opencode.permissions.list, and opencode.questions.list as provider-private " + "operational surfaces rather than portable A2A baseline capabilities." + ), + ( + "Treat a2a.interrupt.* methods as declared shared extensions and opencode.* " + "methods as vendor-specific extensions that remain stable within the current " + "major line." + ), + ( + "Treat opencode.sessions.shell as deployment-conditional and discover it from " + "the declared profile and current wire contract before calling it." + ), + ( + "Treat opencode.workspaces.create/remove and opencode.worktrees.create/remove/" + "reset as deployment-conditional operator surfaces rather than baseline " + "workspace discovery methods." + ), + ( + "Treat opencode.workspaces.list and opencode.worktrees.list as declared " + "adapter contracts over upstream experimental endpoints, not the same " + "stability tier as project discovery." + ), + ( + "Treat opencode.permissions.list and opencode.questions.list as adapter-local, " + "identity-scoped recovery views rather than upstream global pending queues." + ), + ] + ) + else: + consumer_guidance.extend( + [ + ( + "OpenCode API V2 currently provides internal experimental session transport " + "only; A2A message execution and all shared and provider-private extensions " + "are unavailable." + ), + ( + "Do not call model selection, interrupt callbacks, session controls, or " + "a2a_call in OpenCode API V2 mode; these operations are not advertised or " + "implemented." + ), + ] + ) + consumer_guidance.extend( + [ + ( + "Treat declared service behaviors as stable server-level semantic " + "enhancements layered on top of the core A2A method baseline." + ), + ( + "Treat protocol_compatibility as the runtime truth for which major line " + "is fully supported by the current deployment." + ), + ] + ) return { **runtime_profile.summary_dict(protocol_version=declared_protocol_version), "default_protocol_version": declared_default_protocol_version, @@ -253,54 +325,7 @@ def build_compatibility_profile_params( "extension_retention": _build_extension_retention(capability_snapshot), "method_retention": _build_method_retention(capability_snapshot), "service_behaviors": service_behaviors, - "consumer_guidance": [ - "Treat core A2A methods as the stable interoperability baseline for generic clients.", - ( - "Treat this deployment as a single-tenant, shared-workspace coding profile; " - "do not assume per-consumer workspace or tenant isolation." - ), - ( - "Treat shared model selection metadata as a stable request-scoped plugin " - "surface for the main chat path; provider defaults still belong to OpenCode." - ), - ( - "Treat opencode.sessions.*, opencode.providers.*, opencode.models.*, " - "opencode.projects.*, opencode.workspaces.*, opencode.worktrees.*, " - "opencode.permissions.list, and opencode.questions.list as provider-private " - "operational surfaces rather than portable A2A baseline capabilities." - ), - ( - "Treat a2a.interrupt.* methods as declared shared extensions and opencode.* " - "methods as vendor-specific extensions that remain stable within the current " - "major line." - ), - ( - "Treat opencode.sessions.shell as deployment-conditional and discover it from " - "the declared profile and current wire contract before calling it." - ), - ( - "Treat opencode.workspaces.create/remove and opencode.worktrees.create/remove/" - "reset as deployment-conditional operator surfaces rather than baseline " - "workspace discovery methods." - ), - ( - "Treat opencode.workspaces.list and opencode.worktrees.list as declared " - "adapter contracts over upstream experimental endpoints, not the same " - "stability tier as project discovery." - ), - ( - "Treat opencode.permissions.list and opencode.questions.list as adapter-local, " - "identity-scoped recovery views rather than upstream global pending queues." - ), - ( - "Treat declared service behaviors as stable server-level semantic " - "enhancements layered on top of the core A2A method baseline." - ), - ( - "Treat protocol_compatibility as the runtime truth for which major line " - "is fully supported by the current deployment." - ), - ], + "consumer_guidance": consumer_guidance, } diff --git a/src/opencode_a2a/execution/coordinator.py b/src/opencode_a2a/execution/coordinator.py index e0622591..01f2d0ff 100644 --- a/src/opencode_a2a/execution/coordinator.py +++ b/src/opencode_a2a/execution/coordinator.py @@ -123,6 +123,7 @@ def __init__( self._stream_task: asyncio.Task[None] | None = None self._pending_preferred_claim = False self._session_lock: asyncio.Lock | None = None + self._session_lock_acquired = False self._session_id = "" self._execution_key = (task_id, context_id) @@ -132,8 +133,15 @@ async def run(self) -> None: await self._register_running_request(current_task) try: + early_snapshot = getattr( + self._executor._client, "supports_streaming_submission", False + ) is True and (self._prepared.streaming_request or self._prepared.return_immediately) + if early_snapshot: + await self._event_queue.enqueue_event(self._build_initial_task_snapshot()) await self._bind_session() - if self._prepared.streaming_request or self._prepared.return_immediately: + if not early_snapshot and ( + self._prepared.streaming_request or self._prepared.return_immediately + ): await self._event_queue.enqueue_event(self._build_initial_task_snapshot()) turn_request_parts = list(self._prepared.request_parts) @@ -156,6 +164,8 @@ async def run(self) -> None: else user_text or None ) prompt_kwargs = dict(send_kwargs) + if getattr(self._executor._client, "supports_streaming_submission", False) is True: + prompt_kwargs["stream_updates_requested"] = self._prepared.streaming_request if self._prepared.use_structured_parts or turn_request_parts: prompt_kwargs["parts"] = turn_request_parts @@ -209,6 +219,8 @@ async def run(self) -> None: if isinstance(response, ExecutionOutcome) else response.raw ) + if self._execution_key in self._executor._cancelled_requests: + break tool_results = await maybe_handle_tools( response_payload, a2a_client_manager=self._executor._a2a_client_manager, @@ -303,6 +315,7 @@ async def run(self) -> None: async def _register_running_request(self, current_task: asyncio.Task[Any]) -> None: async with self._executor._lock: self._executor._running_requests[self._execution_key] = current_task + self._executor._running_event_queues[self._execution_key] = self._event_queue self._executor._running_stop_events[self._execution_key] = self._stop_event self._executor._running_identities[self._execution_key] = self._prepared.identity @@ -322,6 +335,7 @@ async def _bind_session(self) -> None: self._session_id ) await self._session_lock.acquire() + self._session_lock_acquired = True async with self._executor._lock: self._executor._running_session_ids[self._execution_key] = self._session_id self._executor._running_directories[self._execution_key] = self._prepared.directory @@ -353,6 +367,8 @@ async def _bind_session(self) -> None: ) async def _handle_response(self, response: Any) -> None: + if self._execution_key in self._executor._cancelled_requests: + return response_text = ( response.output if isinstance(response, ExecutionOutcome) else response.text ) or "" @@ -590,10 +606,13 @@ async def _cleanup(self) -> None: self._stream_task.cancel() with suppress(asyncio.CancelledError): await self._stream_task - if self._session_lock and self._session_lock.locked(): + if self._session_lock and self._session_lock_acquired: self._session_lock.release() + self._session_lock_acquired = False async with self._executor._lock: self._executor._running_requests.pop(self._execution_key, None) + self._executor._running_event_queues.pop(self._execution_key, None) + self._executor._cancelled_requests.discard(self._execution_key) self._executor._running_stop_events.pop(self._execution_key, None) self._executor._running_identities.pop(self._execution_key, None) self._executor._running_session_ids.pop(self._execution_key, None) diff --git a/src/opencode_a2a/execution/executor.py b/src/opencode_a2a/execution/executor.py index 1826ce72..1e5702c0 100644 --- a/src/opencode_a2a/execution/executor.py +++ b/src/opencode_a2a/execution/executor.py @@ -24,6 +24,7 @@ TaskStatus, TaskStatusUpdateEvent, ) +from a2a.utils.errors import TaskNotCancelableError from ..contracts.extensions import ( INTERRUPT_CALLBACK_EXTENSION_URI, @@ -42,10 +43,12 @@ ) from ..redact import redact_absolute_paths from ..sandbox_policy import SandboxPolicy +from ..upstream.contracts import ExecutionState, PromptSubmission from .coordinator import ExecutionCoordinator, PreparedExecution, build_session_binding_context_id from .event_helpers import _enqueue_artifact_update from .metrics import emit_metric from .session_manager import SessionManager +from .stream_events import _task_state_from_execution_state from .stream_runtime import StreamRuntime logger = logging.getLogger(__name__) @@ -88,6 +91,8 @@ def __init__( ) self._lock = asyncio.Lock() self._running_requests: dict[tuple[str, str], asyncio.Task[Any]] = {} + self._running_event_queues: dict[tuple[str, str], EventQueue] = {} + self._cancelled_requests: set[tuple[str, str]] = set() self._running_stop_events: dict[tuple[str, str], asyncio.Event] = {} self._running_identities: dict[tuple[str, str], str] = {} self._running_session_ids: dict[tuple[str, str], str] = {} @@ -110,14 +115,16 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non return if not getattr(self._client, "prompt_execution_supported", True): + unsupported_reason = getattr( + self._client, + "prompt_execution_unsupported_reason", + "OpenCode prompt execution is unsupported by the selected upstream adapter.", + ) await self._emit_error( event_queue, task_id=task_id, context_id=context_id, - message=( - "OpenCode V2 prompt execution is not implemented; session transport is " - "experimental and prompt execution will be available in a later release." - ), + message=unsupported_reason, state=TaskState.TASK_STATE_FAILED, error_type="UPSTREAM_UNSUPPORTED", streaming_request=self._should_stream(context), @@ -218,6 +225,22 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non namespace="opencode", path=("directory",), ) + if getattr(self._client, "supports_streaming_submission", False) is True and ( + model_provider_id is not None + or model_id is not None + or workspace_id is not None + or any(part.get("type") != "text" for part in request_parts) + ): + await self._emit_error( + event_queue, + task_id=task_id, + context_id=context_id, + message="V2 exclusive mode supports text only, without model/workspace overrides.", + state=TaskState.TASK_STATE_FAILED, + error_type="UPSTREAM_UNSUPPORTED_OPERATION", + streaming_request=streaming_request, + ) + return directory: str | None = None if workspace_id is None: @@ -320,6 +343,9 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non await coordinator.run() async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + if getattr(self._client, "supports_streaming_submission", False) is True: + await self._cancel_exclusive(context, event_queue) + return task_id = context.task_id context_id = context.context_id started_at = time.monotonic() @@ -341,12 +367,19 @@ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None call_context = context.call_context identity = (call_context.state.get("identity") if call_context else None) or "anonymous" + execution_key = (task_id, context_id) + async with self._lock: + self._cancelled_requests.add(execution_key) + original_queue = self._running_event_queues.get(execution_key) + event = TaskStatusUpdateEvent( task_id=task_id, context_id=context_id, status=TaskStatus(state=TaskState.TASK_STATE_CANCELED), ) await event_queue.enqueue_event(event) + if original_queue is not None and original_queue is not event_queue: + await original_queue.enqueue_event(event) execution_key = (task_id, context_id) async with self._lock: @@ -435,12 +468,85 @@ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None streaming_request=False, ) finally: + if task_id and context_id: + async with self._lock: + if (task_id, context_id) not in self._running_requests: + self._cancelled_requests.discard((task_id, context_id)) emit_metric( "a2a_cancel_duration_ms", (time.monotonic() - started_at) * 1000.0, abort_outcome=abort_outcome, ) + async def _cancel_exclusive(self, context: RequestContext, event_queue: EventQueue) -> None: + task_id = context.task_id + context_id = context.context_id + if not task_id or not context_id: + raise TaskNotCancelableError(message="Cancellation requires a known task") + identity = ( + context.call_context.state.get("identity") if context.call_context else None + ) or "anonymous" + key = (task_id, context_id) + async with self._lock: + owner = self._running_identities.get(key) + task = self._running_requests.get(key) + session = self._running_session_ids.get(key) + directory = self._running_directories.get(key) + original_queue = self._running_event_queues.get(key) + if owner is not None and owner != identity: + raise TaskNotCancelableError(message="Task is not cancelable by this caller") + if task is None or task.done(): + # The SDK may race an execution's final queue write. Let it read that + # terminal result rather than raising into its failure-write path. + return + self._cancelled_requests.add(key) + state = TaskState.TASK_STATE_CANCELED + output = "" + diagnostic: str | None = None + if session is not None: + cancel_invocation = getattr(self._client, "cancel_invocation", None) + if not callable(cancel_invocation): + raise TaskNotCancelableError(message="Upstream has no scoped cancellation") + try: + submission = await asyncio.wait_for( + cancel_invocation(session, task, directory=directory), + timeout=self._cancel_abort_timeout_seconds, + ) + if not isinstance(submission, PromptSubmission) or submission.outcome is None: + raise RuntimeError("Cancellation lacks a settled outcome") + outcome = submission.outcome + state = _task_state_from_execution_state(outcome.state) + output = outcome.output if outcome.state == ExecutionState.COMPLETED else "" + except (TimeoutError, RuntimeError, httpx.HTTPError): + state = TaskState.TASK_STATE_FAILED + diagnostic = "Upstream cancellation is uncertain; session cannot be reused" + else: + # Cancel local waiting work before any await can grant its session lease. + task.cancel() + queues = [event_queue] + if original_queue is not None and original_queue is not event_queue: + queues.append(original_queue) + for queue in queues: + if output or diagnostic: + await _enqueue_artifact_update( + event_queue=queue, + task_id=task_id, + context_id=context_id, + artifact_id=f"{task_id}:cancel-settlement", + part=Part(text=output or diagnostic or ""), + append=False, + last_chunk=True, + ) + await queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=task_id, + context_id=context_id, + status=TaskStatus(state=state), + ) + ) + if not task.done(): + task.cancel() + async def _emit_error( self, event_queue: EventQueue, @@ -453,6 +559,8 @@ async def _emit_error( upstream_status: int | None = None, streaming_request: bool, ) -> None: + if (task_id, context_id) in self._cancelled_requests: + return message = redact_absolute_paths(message) error_message = Message( message_id=str(uuid.uuid4()), diff --git a/src/opencode_a2a/execution/session_manager.py b/src/opencode_a2a/execution/session_manager.py index 25263ab8..14b6f1c6 100644 --- a/src/opencode_a2a/execution/session_manager.py +++ b/src/opencode_a2a/execution/session_manager.py @@ -103,7 +103,7 @@ async def get_or_create_session( try: session_id = await task - except Exception: + except (Exception, asyncio.CancelledError): async with self._registry_lock: if self._inflight_session_creates.get(cache_key) is task: self._inflight_session_creates.pop(cache_key, None) diff --git a/src/opencode_a2a/execution/stream_runtime.py b/src/opencode_a2a/execution/stream_runtime.py index cf811998..66746031 100644 --- a/src/opencode_a2a/execution/stream_runtime.py +++ b/src/opencode_a2a/execution/stream_runtime.py @@ -412,6 +412,8 @@ def _tool_chunks( while not stop_event.is_set(): try: stream_kwargs: dict[str, Any] = {"stop_event": stop_event} + if getattr(self._client, "supports_streaming_submission", False) is True: + stream_kwargs["session_id"] = session_id if directory is not None: stream_kwargs["directory"] = directory if workspace_id is not None: diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index 94776643..339c544d 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -353,7 +353,9 @@ async def _handle_core_request( request.state.activated_extensions = tuple( value for value in requested_extensions - if self._capability_snapshot.extensions_enabled and value in ALL_EXTENSION_URIS + if self._capability_snapshot.extensions_enabled + and value in ALL_EXTENSION_URIS + and self._capability_snapshot.supports_extension(value) ) return JSONResponse( { diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index 6c51753b..53bfa143 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -74,6 +74,15 @@ def _build_agent_card_description( "and OpenCode-specific extensions are unsupported. Agent Card default input/output " "modes are required SDK schema fields, not a claim that message execution works." ) + if capability_snapshot.v2_exclusive_execution: + return ( + f"{base} Experimental OpenCode 2.0.28 exclusive execution: text-only prompts, " + "one task per gateway-created session, parallel distinct sessions, and streaming " + "read-permission/question interventions. Requires a dedicated private upstream, " + "no external or autonomous writers, and protected configuration. Event loss " + "quarantines the session; no shared-session, model-override, or a2a_call support. " + "Unary interventions are unsupported. Single-tenant shared workspace." + ) if not include_detailed_contracts: public_parts: list[str] = [ base, @@ -212,6 +221,8 @@ def _build_agent_extensions( interrupt_callback_extension_params = build_interrupt_callback_extension_params( runtime_profile=runtime_profile, ) + if capability_snapshot.v2_exclusive_execution: + interrupt_callback_extension_params["permission_reply_values"] = ["once", "reject"] compatibility_profile_params = build_compatibility_profile_params( protocol_version=A2A_PROTOCOL_VERSION, runtime_profile=runtime_profile, @@ -296,7 +307,11 @@ def _build_agent_extensions( if tuple(ext.uri for ext in public_extensions) != PUBLIC_EXTENSION_URIS: raise RuntimeError("Agent Card public extension URIs drifted from the declared contract") if not include_detailed_contracts: - return public_extensions + return [ + extension + for extension in public_extensions + if extension.uri in capability_snapshot.declared_extension_uris + ] authenticated_only_extensions = [ AgentExtension( @@ -364,6 +379,12 @@ def _build_agent_extensions( raise RuntimeError( "Agent Card authenticated extension URIs drifted from the declared contract" ) + if capability_snapshot.v2_exclusive_execution: + return [ + extension + for extension in [*public_extensions, *authenticated_only_extensions] + if extension.uri in capability_snapshot.declared_extension_uris + ] return [*public_extensions, *authenticated_only_extensions] @@ -373,6 +394,17 @@ def _build_agent_skills( capability_snapshot: JsonRpcCapabilitySnapshot, include_detailed_contracts: bool, ) -> list[AgentSkill]: + if capability_snapshot.v2_exclusive_execution: + return [ + AgentSkill( + id="opencode.chat.exclusive", + name="Exclusive OpenCode Chat", + description="Text chat in private, gateway-owned single-flight sessions.", + tags=["chat", "exclusive", "experimental"], + input_modes=["text/plain"], + output_modes=list(_CHAT_OUTPUT_MODES), + ) + ] if not capability_snapshot.a2a_message_execution_enabled: return [ AgentSkill( @@ -517,6 +549,7 @@ def build_agent_card( capability_snapshot = capability_snapshot or build_capability_snapshot( runtime_profile=runtime_profile, upstream_api_version=settings.opencode_api_version, + v2_exclusive_execution=settings.opencode_v2_execution == "exclusive", ) security_schemes: dict[str, SecurityScheme] = {} security_requirements: list[SecurityRequirement] = [] @@ -562,7 +595,11 @@ def build_agent_card( protocol_version=A2A_PROTOCOL_VERSION, ), ], - default_input_modes=list(_CHAT_INPUT_MODES), + default_input_modes=( + ["text/plain"] + if capability_snapshot.v2_exclusive_execution + else list(_CHAT_INPUT_MODES) + ), default_output_modes=list(_CHAT_OUTPUT_MODES), capabilities=AgentCapabilities( streaming=capability_snapshot.streaming_enabled, diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index f65df15f..456d11ee 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -180,6 +180,7 @@ def create_app(settings: Settings) -> FastAPI: capability_snapshot = build_capability_snapshot( runtime_profile=runtime_profile, upstream_api_version=settings.opencode_api_version, + v2_exclusive_execution=settings.opencode_v2_execution == "exclusive", ) agent_card = build_agent_card(settings, capability_snapshot=capability_snapshot) extended_agent_card = build_agent_card( diff --git a/src/opencode_a2a/server/middleware.py b/src/opencode_a2a/server/middleware.py index e22fb231..ffcb9e8b 100644 --- a/src/opencode_a2a/server/middleware.py +++ b/src/opencode_a2a/server/middleware.py @@ -490,7 +490,11 @@ def _merge_vary(*values: str) -> str: def _resolve_activated_extensions(request: Request) -> tuple[str, ...]: explicit = getattr(request.state, "activated_extensions", None) if explicit is not None: - return tuple(value for value in explicit if value) + return tuple( + value + for value in explicit + if value and capability_snapshot.supports_extension(value) + ) requested_extensions = tuple( get_requested_extensions(request.headers.getlist(HTTP_EXTENSION_HEADER)) @@ -512,7 +516,11 @@ def _resolve_activated_extensions(request: Request) -> tuple[str, ...]: return () requested = frozenset(requested_extensions) - return tuple(value for value in supported_extensions if value in requested) + return tuple( + value + for value in supported_extensions + if value in requested and capability_snapshot.supports_extension(value) + ) def _set_activated_extensions_header(response: Response, request: Request) -> None: if not (200 <= response.status_code < 400): diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index bc9a27b2..1e342dfa 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -30,6 +30,13 @@ def _build_jsonrpc_extension_openapi_description( capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> str: + if capability_snapshot is not None and capability_snapshot.v2_exclusive_execution: + return ( + "Experimental V2 exclusive text execution in a private, gateway-owned single-flight " + "session. Shared session binding, stream hints and streaming read/question callbacks " + "are supported. Attachments, request-scoped models, arbitrary tools, shared writers, " + "unary interventions and automatic event-loss recovery are unsupported." + ) if capability_snapshot is not None and not capability_snapshot.extensions_enabled: return ( "A2A JSON-RPC retains the A2A protocol 1.0 method surface. With OpenCode API V2, " @@ -259,6 +266,7 @@ def _patch_jsonrpc_openapi_contract( capability_snapshot = capability_snapshot or build_capability_snapshot( runtime_profile=runtime_profile, upstream_api_version=settings.opencode_api_version, + v2_exclusive_execution=settings.opencode_v2_execution == "exclusive", ) session_binding = build_session_binding_extension_params( runtime_profile=runtime_profile, @@ -334,6 +342,11 @@ def custom_openapi() -> dict[str, Any]: ), }, } + post["x-a2a-extension-contracts"] = { + key: contract + for key, contract in post["x-a2a-extension-contracts"].items() + if capability_snapshot.supports_extension(contract["extension_uri"]) + } else: post.pop("x-a2a-extension-contracts", None) @@ -349,6 +362,12 @@ def custom_openapi() -> dict[str, Any]: app_json["examples"] = ( _build_jsonrpc_extension_openapi_examples() ) + if capability_snapshot.v2_exclusive_execution: + for unsupported in ( + "message_send_model_override", + "message_send_file_input", + ): + app_json["examples"].pop(unsupported, None) rest_post_contracts: dict[str, dict[str, Any]] = { "/message:send": { @@ -402,6 +421,16 @@ def custom_openapi() -> dict[str, Any]: app_json["schema"] = {"$ref": contract["schema_ref"]} if capability_snapshot.a2a_message_execution_enabled: app_json["examples"] = rest_examples + if capability_snapshot.v2_exclusive_execution: + app_json["examples"] = { + key: example + for key, example in rest_examples.items() + if "file" not in key and "model" not in key + } + rest_post["description"] += ( + " Exclusive private sessions only; text input, no model override. " + "Unary interventions and automatic gap recovery are unsupported." + ) app.openapi_schema = schema return schema diff --git a/src/opencode_a2a/server/request_handler.py b/src/opencode_a2a/server/request_handler.py index c0122a86..65db9f8c 100644 --- a/src/opencode_a2a/server/request_handler.py +++ b/src/opencode_a2a/server/request_handler.py @@ -251,6 +251,18 @@ async def on_cancel_task( result = await super().on_cancel_task(params, context) if result is None: return None + if ( + getattr( + getattr(self.agent_executor, "_client", None), + "supports_streaming_submission", + False, + ) + is True + and result.status.state != TaskState.TASK_STATE_CANCELED + ): + raise TaskNotCancelableError( + message="Execution settled without proven cancellation; inspect task state" + ) modes = self._modes_for_task(result.id, task=result) return cast(Task, self._negotiate(result, modes)) except TaskNotCancelableError: diff --git a/src/opencode_a2a/upstream/v2.py b/src/opencode_a2a/upstream/v2.py index 85448744..1b306690 100644 --- a/src/opencode_a2a/upstream/v2.py +++ b/src/opencode_a2a/upstream/v2.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping, Sequence +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import replace from typing import Any, cast from urllib.parse import quote @@ -15,7 +16,15 @@ ) from ..sandbox_policy import SandboxPolicy from ..server.state_store import InterruptRequestRepository -from .contracts import PromptSubmission +from .contracts import ExecutionUpdate, PromptSubmission +from .v2_events import V2EventHub, V2ExecutionUncertainError +from .v2_execution import V2ExecutionEngine +from .v2_interventions import V2Interventions +from .v2_profile import ( + exclusive_permissions, + validate_exclusive_configuration, + validate_exclusive_settings, +) class UnsupportedUpstreamOperationError(RuntimeError): @@ -23,9 +32,15 @@ class UnsupportedUpstreamOperationError(RuntimeError): class V2UpstreamAdapter(OpencodeUpstreamClient): - """Explicit V2 session transport using the shared V1 HTTP facade infrastructure.""" + """V2 session transport with an explicitly selected private execution profile.""" prompt_execution_supported = False + prompt_execution_unsupported_reason = ( + "OpenCode V2 prompt execution is disabled: a caller-supplied input ID identifies its " + "inbox/user message, but the upstream contract does not prove a per-input terminal " + "outcome after event loss or support task-scoped active cancellation. " + "No prompt was submitted." + ) def __init__( self, @@ -36,6 +51,32 @@ def __init__( super().__init__(settings, interrupt_request_repository=interrupt_request_repository) self._v2_server_info: Mapping[str, Any] | None = None self._v2_server_info_lock = asyncio.Lock() + self.prompt_execution_supported = settings.opencode_v2_execution == "exclusive" + self.supports_streaming_submission = self.prompt_execution_supported + if self.prompt_execution_supported: + validate_exclusive_settings(settings) + self._v2_hub = V2EventHub(self) + self._v2_execution = V2ExecutionEngine( + self, self._v2_hub, timeout=settings.opencode_timeout_stream or 900 + ) + self._v2_interventions = V2Interventions( + self, validate_lease=self._v2_execution.validate_intervention + ) + self._v2_creation_lock = asyncio.Lock() + self._v2_invocation_tasks: dict[str, asyncio.Task[object]] = {} + + async def close(self) -> None: + await self._v2_hub.close() + await super().close() + + async def _verify_exclusive_profile(self, directory: str) -> None: + info = await self.get_server_info() + if info.get("version") != "2.0.28": + raise UpstreamContractError("V2 exclusive execution is pinned to OpenCode 2.0.28") + query = {"location[directory]": directory} + entries = await self._get_v2_json("/api/config", endpoint="/api/config", params=query) + plugins = await self._get_v2_json("/api/plugin", endpoint="/api/plugin", params=query) + validate_exclusive_configuration(entries, plugins) def _resolve_location(self, directory: str | None, workspace_id: str | None) -> str: if workspace_id is not None and workspace_id.strip(): @@ -137,18 +178,26 @@ async def create_session( ) -> str: resolved_directory = self._resolve_location(directory, workspace_id) payload: dict[str, Any] = {"location": {"directory": resolved_directory}} + if self.prompt_execution_supported: + await self._verify_exclusive_profile(resolved_directory) + payload["permissions"] = exclusive_permissions() if title: payload["title"] = title - response = await self._post_v2_json( - "/api/session", - endpoint="/api/session", - json_body=payload, - ) - data = self._unwrap_data(response, endpoint="/api/session") - session_id = data.get("id") if isinstance(data, Mapping) else None - if not isinstance(session_id, str) or not session_id.strip(): - raise UpstreamContractError("OpenCode V2 /api/session data is missing session id") - return session_id + async with self._v2_creation_lock: + if self.prompt_execution_supported: + self._v2_execution.ensure_capacity() + response = await self._post_v2_json( + "/api/session", + endpoint="/api/session", + json_body=payload, + ) + data = self._unwrap_data(response, endpoint="/api/session") + session_id = data.get("id") if isinstance(data, Mapping) else None + if not isinstance(session_id, str) or not session_id.strip(): + raise UpstreamContractError("OpenCode V2 /api/session data is missing session id") + if self.prompt_execution_supported: + self._v2_execution.register_created(session_id, resolved_directory) + return session_id async def get_session( self, @@ -207,10 +256,7 @@ async def send_message( timeout_override: float | None | object = ..., ) -> OpencodeMessage: del session_id, text, parts, directory, workspace_id, model_override, timeout_override - raise UnsupportedUpstreamOperationError( - "OpenCode V2 prompt execution is not implemented; use OPENCODE_API_VERSION=v1 " - "or wait for the V2 execution support release." - ) + raise UnsupportedUpstreamOperationError(self.prompt_execution_unsupported_reason) async def submit_prompt( self, @@ -222,9 +268,158 @@ async def submit_prompt( workspace_id: str | None = None, model_override: Mapping[str, Any] | None = None, timeout_override: float | None | object = ..., + stream_updates_requested: bool = False, ) -> PromptSubmission: - del session_id, text, parts, directory, workspace_id, model_override, timeout_override - raise UnsupportedUpstreamOperationError( - "OpenCode V2 prompt execution is not implemented; use OPENCODE_API_VERSION=v1 " - "or wait for the V2 execution support release." + if not self.prompt_execution_supported: + raise UnsupportedUpstreamOperationError(self.prompt_execution_unsupported_reason) + if session_id not in self._v2_execution.sessions: + raise UnsupportedUpstreamOperationError( + "V2 execution requires a gateway-created session" + ) + if model_override or (parts and any(part.get("type") != "text" for part in parts)): + raise UnsupportedUpstreamOperationError( + "V2 exclusive execution supports text only, without request-scoped model overrides" + ) + if not isinstance(text, str) or not text.strip(): + raise ValueError("V2 exclusive execution requires nonempty text") + if parts and any(not isinstance(part.get("text"), str) for part in parts): + raise ValueError("V2 text parts require string text") + resolved = self._resolve_location(directory, workspace_id) + await self._verify_exclusive_profile(resolved) + session = await self.get_session(session_id, directory=resolved) + if ( + not isinstance(session, Mapping) + or session.get("permissions") != exclusive_permissions() + ): + raise UpstreamContractError("V2 session permissions do not match the exclusive profile") + current = asyncio.current_task() + if current is None: + raise RuntimeError("V2 execution requires an owned asyncio task") + self._v2_invocation_tasks[session_id] = current + try: + return await self._v2_execution.submit( + session_id, text, emit_updates=stream_updates_requested + ) + finally: + if self._v2_invocation_tasks.get(session_id) is current: + self._v2_invocation_tasks.pop(session_id, None) + + async def stream_updates( + self, + stop_event: asyncio.Event | None = None, + *, + session_id: str | None = None, + directory: str | None = None, + workspace_id: str | None = None, + ) -> AsyncIterator[ExecutionUpdate]: + from ..execution.stream_events import normalize_v1_event + + self._resolve_location(directory, workspace_id) + if not self.prompt_execution_supported: + raise UnsupportedUpstreamOperationError(self.prompt_execution_unsupported_reason) + if session_id is None: + raise ValueError("V2 updates require a gateway-owned session") + stop_event = stop_event or asyncio.Event() + while not stop_event.is_set(): + event = await self._v2_execution.next_update(session_id) + if isinstance(event, ExecutionUpdate): + yield event + continue + try: + canonical = self._v2_interventions.normalize_event(event, session_id=session_id) + if canonical is not None and canonical.get("type") in { + "permission.asked", + "question.asked", + }: + tool = canonical["properties"].get("tool") + message_id = tool.get("messageID") if isinstance(tool, Mapping) else None + if not isinstance(message_id, str): + raise ValueError("V2 intervention requires an observed assistant tool") + self._v2_execution.validate_intervention( + session_id, assistant_message_id=message_id + ) + except (ValueError, V2ExecutionUncertainError) as exc: + self._v2_execution.reject_intervention(session_id) + raise UpstreamContractError(str(exc)) from exc + update = normalize_v1_event(canonical or event, log_limit=0) + if ( + canonical is not None + and canonical.get("type") == "question.asked" + and update is not None + and update.intervention is not None + ): + update = replace( + update, + intervention=replace( + update.intervention, + details={"questions": canonical["properties"]["questions"]}, + ), + ) + if update is not None: + yield update + + async def abort_session( + self, + session_id: str, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> bool: + self._resolve_location(directory, workspace_id) + if not self.prompt_execution_supported: + raise UnsupportedUpstreamOperationError(self.prompt_execution_unsupported_reason) + result = await self._v2_execution.abort(session_id) + return result.outcome is not None and result.outcome.state.value == "cancelled" + + async def cancel_invocation( + self, session_id: str, execution_task: asyncio.Task[object], *, directory: str | None + ) -> PromptSubmission: + self._resolve_location(directory, None) + if self._v2_invocation_tasks.get(session_id) is not execution_task: + raise RuntimeError("Cancellation no longer owns the active invocation") + identity = self._v2_execution.invocation_id(session_id) + return await self._v2_execution.abort(session_id, expected_input_id=identity) + + async def permission_reply( + self, + request_id: str, + *, + reply: str, + message: str | None = None, + directory: str | None = None, + workspace_id: str | None = None, + ) -> bool: + self._resolve_location(directory, workspace_id) + if not self.prompt_execution_supported: + raise UnsupportedUpstreamOperationError(self.prompt_execution_unsupported_reason) + await self._v2_interventions.permission_reply(request_id, reply=reply, message=message) + return True + + async def question_reply( + self, + request_id: str, + *, + answers: Sequence[Sequence[str]], + directory: str | None = None, + workspace_id: str | None = None, + ) -> bool: + self._resolve_location(directory, workspace_id) + if not self.prompt_execution_supported: + raise UnsupportedUpstreamOperationError(self.prompt_execution_unsupported_reason) + await self._v2_interventions.question_reply( + request_id, answers=[list(answer) for answer in answers] ) + return True + + async def question_reject( + self, + request_id: str, + *, + directory: str | None = None, + workspace_id: str | None = None, + ) -> bool: + self._resolve_location(directory, workspace_id) + if not self.prompt_execution_supported: + raise UnsupportedUpstreamOperationError(self.prompt_execution_unsupported_reason) + await self._v2_interventions.question_reject(request_id) + return True diff --git a/src/opencode_a2a/upstream/v2_interventions.py b/src/opencode_a2a/upstream/v2_interventions.py new file mode 100644 index 00000000..e5a2c9e3 --- /dev/null +++ b/src/opencode_a2a/upstream/v2_interventions.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Mapping +from copy import deepcopy +from typing import TYPE_CHECKING, cast +from urllib.parse import quote + +from ..interrupt_request_tracker import BoundInterruptRequestTracker +from ..opencode_upstream_client import UpstreamContractError + +if TYPE_CHECKING: + from .v2 import V2UpstreamAdapter + + +class V2Interventions: + def __init__( + self, client: V2UpstreamAdapter, *, validate_lease: Callable[[str], object] | None = None + ) -> None: + self._client = client + self._tracker = BoundInterruptRequestTracker(client) + self._pending: dict[tuple[str, str], tuple[str, list[dict[str, object]]]] = {} + self._settled: set[tuple[str, str]] = set() + self._locks: dict[str, asyncio.Lock] = {} + self._validate_lease = validate_lease + + @staticmethod + def _fields(value: object) -> list[dict[str, object]]: + if not isinstance(value, list) or not value: + raise ValueError("Question form fields must be a nonempty array") + fields: list[dict[str, object]] = [] + allowed = {"key", "type", "title", "description", "required", "options", "custom"} + for index, field in enumerate(value): + if not isinstance(field, dict) or set(field) - allowed: + raise ValueError("Unsupported question form field constraints") + if field.get("key") != f"q{index}" or field.get("type") not in { + "string", + "multiselect", + }: + raise ValueError("Unsupported question form field type or key") + if any(key in field and type(field[key]) is not bool for key in ("custom", "required")): + raise ValueError("Question field flags must be boolean") + if any( + key in field and not isinstance(field[key], str) for key in ("title", "description") + ): + raise ValueError("Question field labels must be strings") + options = field.get("options", []) + if ( + not isinstance(options, list) + or any( + not isinstance(option, dict) + or not isinstance(option.get("value"), str) + or not isinstance(option.get("label"), str) + or ("description" in option and not isinstance(option["description"], str)) + for option in options + ) + or (field["type"] == "multiselect" and "options" not in field) + ): + raise ValueError("Question field options must be string options") + fields.append(deepcopy(field)) + return fields + + def normalize_event(self, event: Mapping[str, object], *, session_id: str) -> dict | None: + kind, props = event.get("type"), event.get("properties") + if not isinstance(props, Mapping): + return None + form = props.get("form") if kind == "form.created" else props + if not isinstance(form, Mapping) or form.get("sessionID") != session_id: + return None + request = form.get("requestID") if kind == "permission.replied" else form.get("id") + if not isinstance(request, str) or not request: + return None + key = (session_id, request) + if kind in {"form.replied", "form.cancelled", "permission.replied"}: + pending = self._pending.pop(key, None) + self._settled.discard(key) + self._locks.pop(request, None) + if pending is None: + return None + resolved = "question.rejected" if kind == "form.cancelled" else f"{pending[0]}.replied" + return {"type": resolved, "properties": {**props, "requestID": request}} + if kind == "permission.asked": + if len(self._pending) >= 1024: + raise ValueError("V2 pending intervention capacity exceeded") + resources = form.get("resources") + if not isinstance(form.get("action"), str) or not isinstance(resources, list): + raise ValueError("Invalid permission request") + if any(not isinstance(resource, str) for resource in resources): + raise ValueError("Permission resources must be strings") + details = {**form, "permission": form["action"], "patterns": form["resources"]} + source = form.get("source") + if isinstance(source, Mapping) and source.get("type") == "tool": + details["tool"] = {"messageID": source.get("messageID"), "callID": source.get("id")} + self._pending[key] = ("permission", []) + return {"type": kind, "properties": details} + metadata = form.get("metadata") + if kind != "form.created": + return None + if not isinstance(metadata, Mapping) or metadata.get("kind") != "question": + raise ValueError("Unsupported V2 form kind; only question forms are supported") + fields = self._fields(form.get("fields")) + if len(self._pending) >= 1024: + raise ValueError("V2 pending intervention capacity exceeded") + tool = metadata.get("tool") + details = { + "id": request, + "sessionID": session_id, + "questions": [ + { + "header": field.get("title", ""), + "question": field.get("description", ""), + "options": deepcopy(field.get("options", [])), + "multiple": field["type"] == "multiselect", + "custom": field.get("custom", False), + } + for field in fields + ], + } + if isinstance(tool, Mapping): + details["tool"] = {"messageID": tool.get("messageID"), "callID": tool.get("id")} + self._pending[key] = ("question", fields) + return {"type": "question.asked", "properties": details} + + async def _bound(self, request_id: str, kind: str) -> tuple[str, list[dict[str, object]]]: + resolution = await self._tracker.resolve_request(request_id) + binding = resolution.binding + if resolution.status != "active" or binding is None: + raise ValueError("Intervention request is missing or expired") + pending = self._pending.get((binding.session_id, request_id)) + if binding.interrupt_type != kind or pending is None or pending[0] != kind: + raise ValueError("Intervention session or type does not match observed request") + if (binding.session_id, request_id) in self._settled: + raise ValueError("Intervention request is already settled") + if self._validate_lease is not None: + self._validate_lease(binding.session_id) + return binding.session_id, pending[1] + + async def _request( + self, request_id: str, kind: str, method: str, payload: dict[str, object] | None = None + ) -> object: + # Resolve before allocating a lock, so guessed IDs cannot grow this registry. + await self._bound(request_id, kind) + lock = self._locks.setdefault(request_id, asyncio.Lock()) + async with lock: + return await self._request_bound(request_id, kind, method, payload) + + async def _request_bound( + self, request_id: str, kind: str, method: str, payload: dict[str, object] | None + ) -> object: + session, _ = await self._bound(request_id, kind) + resource = "form" if kind == "question" else "permission" + path = f"/api/session/{quote(session, safe='')}/{resource}/{quote(request_id, safe='')}" + if method == "POST": + path += "/reply" + await self._client.get_server_info() + async with self._client._request_budget.reserve(operation=path): + # Recheck after asynchronous prerequisites, immediately before dispatch. + await self._bound(request_id, kind) + response = await self._client._client.request( + method, path, headers=self._client._request_headers(), json=payload + ) + response.raise_for_status() + if method == "GET": + data = self._client._unwrap_data( + self._client._decode_json_response(response, endpoint=path), endpoint=path + ) + if ( + not isinstance(data, Mapping) + or data.get("sessionID") != session + or data.get("id") != request_id + ): + raise UpstreamContractError("V2 intervention response has mismatched ownership") + return data + if response.status_code != 204: + raise UpstreamContractError("V2 intervention mutation must return HTTP 204") + if (session, request_id) in self._pending: + self._settled.add((session, request_id)) + await self._tracker.discard_request(request_id) + return None + + async def get(self, request_id: str, *, interrupt_type: str) -> object: + return await self._request(request_id, interrupt_type, "GET") + + async def permission_reply( + self, request_id: str, *, reply: str, message: str | None = None + ) -> bool: + if reply not in {"once", "reject"}: + raise ValueError("Restricted V2 permissions support only once or reject") + if message is not None and not isinstance(message, str): + raise ValueError("Permission message must be a string") + payload: dict[str, object] = {"decision": reply} + if message is not None: + payload["message"] = message + await self._request(request_id, "permission", "POST", payload) + return True + + async def question_reply(self, request_id: str, *, answers: list[list[str]]) -> bool: + _, fields = await self._bound(request_id, "question") + if not isinstance(answers, list) or len(answers) != len(fields): + raise ValueError("Answers must match the observed question field count") + answer: dict[str, object] = {} + for field, group in zip(fields, answers, strict=True): + if not isinstance(group, list) or any(not isinstance(value, str) for value in group): + raise ValueError("Answers must contain only arrays of strings") + if field["type"] == "string" and len(group) > 1: + raise ValueError("String question accepts a single answer") + if not group: + if field.get("required"): + raise ValueError("Missing required question answer") + continue + options = field.get("options") + if isinstance(options, list) and not field.get("custom", False): + values = {option["value"] for option in options} + if any(value not in values for value in group): + raise ValueError("Answer is not a declared question option") + if field.get("required") and field["type"] == "string" and not group[0]: + raise ValueError("Missing required question answer") + answer[cast(str, field["key"])] = group if field["type"] == "multiselect" else group[0] + await self._request(request_id, "question", "POST", {"answer": answer}) + return True + + async def question_reject(self, request_id: str) -> bool: + await self._request(request_id, "question", "DELETE") + return True diff --git a/src/opencode_a2a/upstream/v2_profile.py b/src/opencode_a2a/upstream/v2_profile.py new file mode 100644 index 00000000..b7decc18 --- /dev/null +++ b/src/opencode_a2a/upstream/v2_profile.py @@ -0,0 +1,59 @@ +"""Preconditions for the experimental, dedicated V2 execution profile.""" + +from __future__ import annotations + +from collections.abc import Mapping +from urllib.parse import urlsplit + +from ..config import Settings +from ..opencode_upstream_client import UpstreamContractError + + +def validate_exclusive_settings(settings: Settings) -> None: + address = urlsplit(settings.opencode_base_url) + if address.hostname != "127.0.0.1" or address.username or address.password: + raise ValueError("V2 exclusive execution requires a private loopback upstream URL") + if not settings.opencode_auth_password: + raise ValueError("V2 exclusive execution requires separate upstream Basic credentials") + if not settings.opencode_workspace_root: + raise ValueError("V2 exclusive execution requires an explicit workspace root") + if settings.a2a_task_store_backend != "database": + raise ValueError("V2 exclusive execution requires durable database-backed ownership") + if settings.opencode_agent or settings.opencode_system or settings.opencode_variant: + raise ValueError( + "V2 exclusive execution does not support V1 agent/system/variant overrides" + ) + + +def validate_exclusive_configuration(entries: object, plugins: object) -> None: + if not isinstance(entries, list) or not isinstance(plugins, Mapping): + raise UpstreamContractError("Cannot verify V2 exclusive configuration") + for entry in entries: + if not isinstance(entry, Mapping): + raise UpstreamContractError("Invalid V2 configuration entry") + if entry.get("type") != "document": + continue + info = entry.get("info") + if not isinstance(info, Mapping): + raise UpstreamContractError("Invalid V2 configuration document") + if info.get("warming") not in (None, False): + raise UpstreamContractError("V2 exclusive execution prohibits warming") + mcp = info.get("mcp") + if info.get("plugins") or (isinstance(mcp, Mapping) and mcp.get("servers")): + raise UpstreamContractError("V2 exclusive execution prohibits custom plugins and MCP") + inventory = plugins.get("data") + if not isinstance(inventory, list): + raise UpstreamContractError("Invalid V2 plugin inventory") + for plugin in inventory: + source = plugin.get("source") if isinstance(plugin, Mapping) else None + if not isinstance(source, Mapping) or source.get("type") != "builtin": + raise UpstreamContractError("V2 exclusive execution permits builtin plugins only") + + +def exclusive_permissions() -> list[dict[str, str]]: + return [ + {"action": "*", "resource": "*", "effect": "deny"}, + {"action": "question", "resource": "*", "effect": "allow"}, + {"action": "read", "resource": "*", "effect": "ask"}, + {"action": "external_directory", "resource": "*", "effect": "deny"}, + ] diff --git a/tests/contracts/test_extension_contract_consistency.py b/tests/contracts/test_extension_contract_consistency.py index e2761726..3f9d3262 100644 --- a/tests/contracts/test_extension_contract_consistency.py +++ b/tests/contracts/test_extension_contract_consistency.py @@ -501,6 +501,16 @@ def test_v2_capability_snapshot_disables_every_catalogued_extension() -> None: assert wire_contract["all_jsonrpc_methods"] == list(catalog.CORE_JSONRPC_METHODS) assert compatibility_profile["extension_retention"] == {} assert set(compatibility_profile["method_retention"]) == set(catalog.CORE_JSONRPC_METHODS) + v2_guidance = " ".join(compatibility_profile["consumer_guidance"]) + assert "OpenCode API V2 currently provides internal experimental session transport only" in ( + v2_guidance + ) + assert ( + "A2A message execution and all shared and provider-private extensions are unavailable" + in v2_guidance + ) + assert "request-scoped plugin surface for the main chat path" not in v2_guidance + assert "a2a.interrupt.* methods as declared shared extensions" not in v2_guidance assert all(not snapshot.is_method_enabled(method) for method in extension_methods) diff --git a/tests/contracts/test_v2_exclusive_capabilities.py b/tests/contracts/test_v2_exclusive_capabilities.py new file mode 100644 index 00000000..29ade3b3 --- /dev/null +++ b/tests/contracts/test_v2_exclusive_capabilities.py @@ -0,0 +1,123 @@ +import json + +import httpx +import pytest + +from opencode_a2a.contracts.extensions import catalog +from opencode_a2a.server.application import create_app +from tests.support.settings import make_settings + + +@pytest.mark.asyncio +async def test_exclusive_discovery_and_unsupported_calls_never_reach_upstream() -> None: + settings = make_settings( + opencode_api_version="v2", + opencode_v2_execution="exclusive", + opencode_workspace_root="/workspace", + opencode_auth_password="synthetic-upstream-password", + ) + app = create_app(settings) + requests: list[httpx.Request] = [] + + def upstream(request: httpx.Request) -> httpx.Response: + requests.append(request) + raise AssertionError("Unsupported calls must not contact upstream") + + app.state.upstream_client._client._transport = httpx.MockTransport(upstream) + headers = {"Authorization": "Bearer test-token"} + methods = { + *catalog.SESSION_METHODS.values(), + *catalog.PROVIDER_DISCOVERY_METHODS.values(), + *catalog.WORKSPACE_CONTROL_METHODS.values(), + } + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test", headers=headers + ) as client: + response = await client.get( + "/.well-known/agent-card.json", + headers={ + "A2A-Extensions": ( + "urn:opencode-a2a:extension:model-selection:v1," + "urn:opencode-a2a:extension:interactive-interrupt:v1" + ) + }, + ) + assert response.status_code == 200 + card = response.json() + assert card["capabilities"]["streaming"] is True + assert card["defaultInputModes"] == ["text/plain"] + uris = {extension["uri"] for extension in card["capabilities"]["extensions"]} + assert "urn:opencode-a2a:extension:interactive-interrupt:v1" in uris + assert "urn:opencode-a2a:extension:model-selection:v1" not in uris + assert "model-selection" not in response.headers.get("A2A-Extensions", "") + schema = (await client.get("/openapi.json")).json() + root = schema["paths"]["/"]["post"] + assert "model_selection" not in root["x-a2a-extension-contracts"] + assert ( + "message_send_file_input" + not in root["requestBody"]["content"]["application/json"]["examples"] + ) + for method in sorted(methods): + result = await client.post( + "/", json={"jsonrpc": "2.0", "id": method, "method": method, "params": {}} + ) + assert result.status_code == 200 + assert result.json()["error"]["code"] == -32601 + assert requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "metadata", + [ + {"shared": {"model": {"providerID": "openai", "modelID": "other"}}}, + {"shared": {"model": {"modelID": "incomplete"}}}, + {"opencode": {"workspace": {"id": "foreign"}}}, + ], +) +async def test_exclusive_model_and_workspace_overrides_fail_before_upstream(metadata: dict) -> None: + app = create_app( + make_settings( + opencode_api_version="v2", + opencode_v2_execution="exclusive", + opencode_workspace_root="/workspace", + opencode_auth_password="synthetic-upstream-password", + ) + ) + requests: list[httpx.Request] = [] + + def upstream(request: httpx.Request) -> httpx.Response: + requests.append(request) + raise AssertionError("Overrides must fail before session creation") + + app.state.upstream_client._client._transport = httpx.MockTransport(upstream) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": "Bearer test-token"}, + ) as client: + response = await client.post( + "/message:send", + headers={ + "A2A-Extensions": ",".join( + [ + "urn:opencode-a2a:extension:session-binding:v1", + "urn:opencode-a2a:extension:model-selection:v1", + "urn:opencode-a2a:extension:workspace-control:v1", + ] + ) + }, + json={ + "message": { + "messageId": "unsupported", + "role": "ROLE_USER", + "parts": [{"text": "must not execute"}], + }, + "metadata": metadata, + }, + ) + assert response.status_code == 200 + assert "TASK_STATE_FAILED" in json.dumps(response.json()) + assert requests == [] diff --git a/tests/execution/test_cancel_waiting_session.py b/tests/execution/test_cancel_waiting_session.py new file mode 100644 index 00000000..7a2bc7c4 --- /dev/null +++ b/tests/execution/test_cancel_waiting_session.py @@ -0,0 +1,54 @@ +import asyncio + +import pytest + +from opencode_a2a.execution.executor import OpencodeAgentExecutor +from tests.support.helpers import DummyEventQueue, make_request_context +from tests.support.streaming_output import DummyStreamingClient + + +@pytest.mark.asyncio +async def test_cancelled_waiter_does_not_release_another_tasks_session_lock() -> None: + client = DummyStreamingClient(stream_events_payload=[], response_text="ok", send_delay=0.2) + executor = OpencodeAgentExecutor(client, streaming_enabled=False) + metadata = {"shared": {"session": {"id": "ses-shared"}}} + owner = asyncio.create_task( + executor.execute( + make_request_context( + task_id="owner", context_id="ctx-owner", text="first", metadata=metadata + ), + DummyEventQueue(), + ) + ) + waiter = None + try: + async with asyncio.timeout(1): + while client._in_flight_send == 0: + await asyncio.sleep(0) + lock = await executor._session_manager.get_session_lock("ses-shared") + waiter = asyncio.create_task( + executor.execute( + make_request_context( + task_id="waiter", context_id="ctx-waiter", text="second", metadata=metadata + ), + DummyEventQueue(), + ) + ) + async with asyncio.timeout(1): + while ("waiter", "ctx-waiter") not in executor._running_requests: + await asyncio.sleep(0) + await asyncio.sleep(0) + waiter.cancel() + await asyncio.gather(waiter, return_exceptions=True) + assert lock.locked() + assert not owner.done() + await owner + assert client.max_in_flight_send == 1 + assert not lock.locked() + finally: + owner.cancel() + if waiter is not None: + waiter.cancel() + await asyncio.gather( + owner, *([waiter] if waiter is not None else []), return_exceptions=True + ) diff --git a/tests/execution/test_v2_executor_cancellation.py b/tests/execution/test_v2_executor_cancellation.py new file mode 100644 index 00000000..1e118617 --- /dev/null +++ b/tests/execution/test_v2_executor_cancellation.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +import httpx +import pytest +import pytest_asyncio +from a2a.server.agent_execution import RequestContext +from a2a.server.context import ServerCallContext +from a2a.types import Task, TaskState, TaskStatusUpdateEvent +from a2a.utils.errors import TaskNotCancelableError + +from opencode_a2a.execution.executor import OpencodeAgentExecutor +from opencode_a2a.upstream.v2 import V2UpstreamAdapter +from opencode_a2a.upstream.v2_events import V2ExecutionUncertainError +from tests.support.helpers import DummyEventQueue, make_request_context +from tests.support.settings import make_settings +from tests.upstream.test_v2_execution import EventStream, NativeServer + + +class ExecutorServer(NativeServer): + """Native HTTP boundary with the actual adapter, executor and session manager.""" + + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: + self.mode = "waiting" + self.cancel_mode = "interrupt" + self.feed = EventStream() + self.feed.send({"type": "server.connected"}) + self.requests: list[httpx.Request] = [] + self.messages: dict[str, list[dict[str, object]]] = {} + self.inputs: list[str] = [] + self.sequences: dict[str, int] = {} + self.admitted = asyncio.Event() + self.interrupt_entered = asyncio.Event() + self.interrupt_release = asyncio.Event() + self.client = V2UpstreamAdapter( + make_settings( + opencode_api_version="v2", + opencode_v2_execution="exclusive", + opencode_workspace_root="/workspace", + opencode_auth_password="synthetic-test-password", + ) + ) + monkeypatch.setattr(self.client._client, "_transport", httpx.MockTransport(self.handle)) + self.hub = self.client._v2_hub + self.engine = self.client._v2_execution + self.engine.timeout = 1 + self.executor = OpencodeAgentExecutor(self.client, streaming_enabled=False) + self.tasks: list[asyncio.Task[None]] = [] + + async def handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/api/session/ses_one" and request.method == "GET": + from opencode_a2a.upstream.v2_profile import exclusive_permissions + + self.requests.append(request) + return httpx.Response(200, json={"data": {"permissions": exclusive_permissions()}}) + if path in {"/api/info", "/api/config", "/api/plugin", "/api/session"}: + self.requests.append(request) + payload: object = { + "/api/info": {"version": "2.0.28"}, + "/api/config": [], + "/api/plugin": {"data": []}, + "/api/session": {"data": {"id": "ses_one"}}, + }[path] + return httpx.Response(200, json=payload) + if path.endswith("/interrupt") and self.cancel_mode in {"refused", "timeout", "http-error"}: + self.requests.append(request) + self.interrupt_entered.set() + if self.cancel_mode == "timeout": + await self.interrupt_release.wait() + if self.cancel_mode == "http-error": + return httpx.Response(503) + self.execution("ses_one", self.inputs[0]) + return httpx.Response(200, json={"interrupted": False}) + return await super().handle(request) + + def context(self, task_id: str = "active", identity: str = "owner") -> RequestContext: + return make_request_context( + task_id=task_id, + context_id="conversation", + text="run this turn", + call_context=ServerCallContext(state={"identity": identity}), + ) + + def start(self, task_id: str = "active") -> tuple[asyncio.Task[None], DummyEventQueue]: + queue = DummyEventQueue() + task = asyncio.create_task(self.executor.execute(self.context(task_id), queue)) + self.tasks.append(task) + return task, queue + + async def close(self) -> None: + for task in self.tasks: + task.cancel() + await asyncio.gather(*self.tasks, return_exceptions=True) + await super().close() + + +@pytest_asyncio.fixture +async def server(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ExecutorServer]: + boundary = ExecutorServer(monkeypatch) + try: + yield boundary + finally: + await boundary.close() + + +def states(queue: DummyEventQueue) -> list[TaskState]: + return [ + event.status.state + for event in queue.events + if isinstance(event, (Task, TaskStatusUpdateEvent)) + ] + + +@pytest.mark.asyncio +async def test_missing_cancel_context_has_no_status_or_native_side_effect( + server: ExecutorServer, +) -> None: + queue = DummyEventQueue() + with pytest.raises(TaskNotCancelableError, match="known task"): + await server.executor.cancel(RequestContext(call_context=None), queue) + assert queue.events == [] + assert server.requests == [] + + +@pytest.mark.asyncio +async def test_unknown_task_cannot_cancel_or_contact_native_server(server: ExecutorServer) -> None: + queue = DummyEventQueue() + await server.executor.cancel(server.context("unknown"), queue) + assert queue.events == [] + assert server.requests == [] + + +@pytest.mark.asyncio +async def test_wrong_principal_cannot_interrupt_active_owner(server: ExecutorServer) -> None: + task, execution = server.start() + await asyncio.wait_for(server.admitted.wait(), 1) + queue = DummyEventQueue() + count = len(server.requests) + with pytest.raises(TaskNotCancelableError, match="this caller"): + await server.executor.cancel(server.context(identity="intruder"), queue) + assert queue.events == [] and len(server.requests) == count + assert not task.done() and server.engine._active == 1 + server.execution("ses_one", server.inputs[0]) + await asyncio.wait_for(task, 1) + assert states(execution) == [TaskState.TASK_STATE_COMPLETED] + + +@pytest.mark.asyncio +async def test_local_queued_cancel_preserves_active_owner_lock_without_interrupt( + server: ExecutorServer, +) -> None: + active, execution = server.start() + await asyncio.wait_for(server.admitted.wait(), 1) + queued, queued_execution = server.start("queued") + async with asyncio.timeout(1): + while ("queued", "conversation") not in server.executor._running_requests: + await asyncio.sleep(0) + lock = await server.executor._session_manager.get_session_lock("ses_one") + queue = DummyEventQueue() + await server.executor.cancel(server.context("queued"), queue) + await asyncio.gather(queued, return_exceptions=True) + assert queued.cancelled() and states(queue) == [TaskState.TASK_STATE_CANCELED] + assert states(queued_execution) == [TaskState.TASK_STATE_CANCELED] + assert ("queued", "conversation") not in server.executor._running_requests + assert server.executor._running_requests[("active", "conversation")] is active + assert lock.locked() and not active.done() and server.engine._active == 1 + assert len(server.inputs) == 1 + assert not any(request.url.path.endswith("/interrupt") for request in server.requests) + assert server.client._v2_invocation_tasks["ses_one"] is active + server.execution("ses_one", server.inputs[0]) + await asyncio.wait_for(active, 1) + assert states(execution) == [TaskState.TASK_STATE_COMPLETED] + assert not lock.locked() and server.engine._active == 0 + + +@pytest.mark.asyncio +async def test_active_cancel_requires_observed_terminal_and_allows_healthy_followup( + server: ExecutorServer, +) -> None: + task, execution = server.start() + await asyncio.wait_for(server.admitted.wait(), 1) + queue = DummyEventQueue() + await server.executor.cancel(server.context(), queue) + await asyncio.wait_for(task, 1) + assert states(queue) == [TaskState.TASK_STATE_CANCELED] + assert states(execution) and set(states(execution)) == {TaskState.TASK_STATE_CANCELED} + assert server.engine._active == 0 + assert server.engine.sessions["ses_one"].subscription.error is None + lock = await server.executor._session_manager.get_session_lock("ses_one") + assert not lock.locked() and server.client._v2_invocation_tasks == {} + interrupts = [request for request in server.requests if request.url.path.endswith("/interrupt")] + assert len(interrupts) == 1 and interrupts[0].url.params["resume"] == "false" + server.mode = "success" + followup, completed = server.start("followup") + await asyncio.wait_for(followup, 1) + assert states(completed) == [TaskState.TASK_STATE_COMPLETED] + assert len(set(server.inputs)) == 2 and server.engine._active == 0 + settled = DummyEventQueue() + count = len(server.requests) + await server.executor.cancel(server.context("followup"), settled) + assert settled.events == [] and len(server.requests) == count + + +@pytest.mark.asyncio +async def test_refused_interrupt_naturally_completes_without_false_canceled_status( + server: ExecutorServer, +) -> None: + server.cancel_mode = "refused" + task, execution = server.start() + await asyncio.wait_for(server.admitted.wait(), 1) + queue = DummyEventQueue() + await server.executor.cancel(server.context(), queue) + await asyncio.wait_for(task, 1) + assert states(queue) == [TaskState.TASK_STATE_COMPLETED] + assert states(execution) == [TaskState.TASK_STATE_COMPLETED] + assert TaskState.TASK_STATE_CANCELED not in states(execution) + assert server.interrupt_entered.is_set() and server.engine._active == 0 + assert server.engine.sessions["ses_one"].subscription.error is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["timeout", "http-error"]) +async def test_uncertain_cancel_never_reports_canceled_and_quarantines_session( + server: ExecutorServer, mode: str +) -> None: + server.cancel_mode = mode + server.executor._cancel_abort_timeout_seconds = 0.05 + task, execution = server.start() + await asyncio.wait_for(server.admitted.wait(), 1) + queue = DummyEventQueue() + await server.executor.cancel(server.context(), queue) + await asyncio.wait_for(asyncio.gather(task, return_exceptions=True), 1) + assert states(queue) == [TaskState.TASK_STATE_FAILED] + assert TaskState.TASK_STATE_CANCELED not in states(execution) + assert states(execution) == [TaskState.TASK_STATE_FAILED] + assert server.interrupt_entered.is_set() + assert server.engine.sessions["ses_one"].subscription.error is not None + assert server.engine._active == 1 + count = len(server.requests) + with pytest.raises(V2ExecutionUncertainError): + await server.engine.submit("ses_one", "unsafe retry") + assert len(server.requests) == count and len(server.inputs) == 1 diff --git a/tests/execution/test_v2_prompt_failfast.py b/tests/execution/test_v2_prompt_failfast.py index fe3bd53e..9e0e9cbb 100644 --- a/tests/execution/test_v2_prompt_failfast.py +++ b/tests/execution/test_v2_prompt_failfast.py @@ -16,6 +16,9 @@ async def test_v2_prompt_is_failed_before_session_or_upstream_side_effect() -> N client.settings = make_settings(opencode_api_version="v2") client.directory = "/tmp/opencode-v2-workspace" client.prompt_execution_supported = False + client.prompt_execution_unsupported_reason = ( + "V2 input ID identifies its user message, but active cancellation cannot be proven." + ) executor = OpencodeAgentExecutor(client, streaming_enabled=True) queue = DummyEventQueue() @@ -27,6 +30,9 @@ async def test_v2_prompt_is_failed_before_session_or_upstream_side_effect() -> N tasks = [event for event in queue.events if isinstance(event, Task)] assert tasks assert tasks[-1].status.state == TaskState.TASK_STATE_FAILED - assert "V2 prompt execution is not implemented" in tasks[-1].status.message.parts[0].text + assert "active cancellation cannot be proven" in tasks[-1].status.message.parts[0].text client.create_session.assert_not_awaited() client.send_message.assert_not_awaited() + client.submit_prompt.assert_not_awaited() + client.stream_events.assert_not_awaited() + client.abort_session.assert_not_awaited() diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index 739749c1..847c664b 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -192,10 +192,7 @@ async def record_upstream_call(path: str, **_kwargs) -> None: assert send_message.status_code == 200 failed_task = send_message.json()["result"]["task"] assert failed_task["status"]["state"] == "TASK_STATE_FAILED" - assert ( - "OpenCode V2 prompt execution is not implemented" - in (failed_task["status"]["message"]["parts"][0]["text"]) - ) + assert "execution is disabled" in failed_task["status"]["message"]["parts"][0]["text"] assert send_streaming.status_code == 200 assert send_streaming.json()["error"]["message"] == "Streaming is not supported by the agent" assert upstream_calls == [] diff --git a/tests/server/test_v2_cancel_http_boundary.py b/tests/server/test_v2_cancel_http_boundary.py new file mode 100644 index 00000000..9465d40f --- /dev/null +++ b/tests/server/test_v2_cancel_http_boundary.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from pathlib import Path + +import httpx +import pytest +import pytest_asyncio + +from opencode_a2a.server.application import create_app +from tests.support.settings import make_settings +from tests.upstream.test_v2_execution import EventStream, NativeServer + + +class HttpBoundary(NativeServer): + """Real host/SDK lifecycle; only the native HTTP server is simulated.""" + + def __init__(self, tmp_path: Path) -> None: + self.app = create_app( + make_settings( + opencode_api_version="v2", + opencode_v2_execution="exclusive", + opencode_workspace_root="/workspace", + opencode_auth_password="synthetic-test-password", + a2a_task_store_backend="database", + a2a_task_store_database_url=f"sqlite+aiosqlite:///{tmp_path / 'tasks.db'}", + a2a_cancel_abort_timeout_seconds=0.1, + ) + ) + self.client = self.app.state.upstream_client + self.engine = self.client._v2_execution + self.engine.timeout = 3 + self.mode = "waiting" + self.cancel_mode = "interrupt" + self.feed = EventStream() + self.feed.send({"type": "server.connected"}) + self.requests: list[httpx.Request] = [] + self.messages: dict[str, list[dict[str, object]]] = {} + self.inputs: list[str] = [] + self.sequences: dict[str, int] = {} + self.admitted = asyncio.Event() + self.settling = asyncio.Event() + self.settle_release = asyncio.Event() + self.pending: list[asyncio.Task[httpx.Response]] = [] + + async def handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/api/session/ses_one" and request.method == "GET": + from opencode_a2a.upstream.v2_profile import exclusive_permissions + + self.requests.append(request) + return httpx.Response(200, json={"data": {"permissions": exclusive_permissions()}}) + if path.endswith("/wait") and self.cancel_mode == "settling": + self.settling.set() + await self.settle_release.wait() + return httpx.Response(204) + if path in {"/api/info", "/api/config", "/api/plugin", "/api/session"}: + self.requests.append(request) + return httpx.Response( + 200, + json={ + "/api/info": {"version": "2.0.28"}, + "/api/config": [], + "/api/plugin": {"data": []}, + "/api/session": {"data": {"id": "ses_one"}}, + }[path], + ) + if path.endswith("/interrupt") and self.cancel_mode != "interrupt": + self.requests.append(request) + if self.cancel_mode == "timeout": + await asyncio.Event().wait() + if self.cancel_mode == "http-error": + return httpx.Response(503) + self.execution("ses_one", self.inputs[0]) + return httpx.Response(200, json={"interrupted": False}) + return await super().handle(request) + + async def start(self, http: httpx.AsyncClient) -> tuple[str, asyncio.Task[httpx.Response]]: + executor = self.app.state.agent_executor + previous = set(executor._running_requests) + stream = asyncio.create_task( + http.post( + "/message:stream", + json={ + "message": { + "messageId": f"input-{len(self.pending)}", + "contextId": "conversation", + "role": "ROLE_USER", + "parts": [{"text": "wait for cancellation"}], + } + }, + ) + ) + self.pending.append(stream) + async with asyncio.timeout(2): + while True: + keys = set(executor._running_requests) - previous + if keys: + task_id = next(iter(keys))[0] + response = await http.get(f"/tasks/{task_id}") + if response.status_code == 200: + return task_id, stream + if stream.done(): + raise AssertionError(f"Stream ended before execution: {(await stream).text}") + await asyncio.sleep(0.001) + + +@pytest_asyncio.fixture +async def boundary( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> AsyncIterator[tuple[HttpBoundary, httpx.AsyncClient]]: + server = HttpBoundary(tmp_path) + monkeypatch.setattr(server.client._client, "_transport", httpx.MockTransport(server.handle)) + async with server.app.router.lifespan_context(server.app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=server.app), + base_url="http://test", + headers={"Authorization": "Bearer test-token", "A2A-Version": "1.0"}, + ) as http: + try: + yield server, http + finally: + for stream in server.pending: + stream.cancel() + await asyncio.gather(*server.pending, return_exceptions=True) + assert server.feed.closed + + +def stream_states(response: httpx.Response) -> list[str]: + events = [ + json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ") + ] + return [event["statusUpdate"]["status"]["state"] for event in events if "statusUpdate" in event] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("interrupt", "TASK_STATE_CANCELED"), + ("refused", "TASK_STATE_COMPLETED"), + ("settling", "TASK_STATE_COMPLETED"), + ("http-error", "TASK_STATE_FAILED"), + ("timeout", "TASK_STATE_FAILED"), + ], +) +async def test_cancel_preserves_observed_outcome_through_real_sdk(boundary, mode, expected) -> None: + server, http = boundary + server.cancel_mode = mode + task_id, stream = await server.start(http) + await asyncio.wait_for(server.admitted.wait(), 2) + if mode == "settling": + server.execution("ses_one", server.inputs[0]) + await asyncio.wait_for(server.settling.wait(), 2) + cancel = asyncio.create_task(http.post(f"/tasks/{task_id}:cancel", json={})) + server.pending.append(cancel) + if mode == "settling": + async with asyncio.timeout(2): + executor = server.app.state.agent_executor + while (task_id, "conversation") not in executor._cancelled_requests: + await asyncio.sleep(0) + server.settle_release.set() + canceled = await asyncio.wait_for(cancel, 2) + finished = await asyncio.wait_for(stream, 2) + stored = await http.get(f"/tasks/{task_id}") + assert stored.status_code == 200 and stored.json()["status"]["state"] == expected + assert finished.status_code == 200 and stream_states(finished)[-1] == expected + assert "TASK_STATE_CANCELED" not in stream_states(finished) or mode == "interrupt" + if mode == "interrupt": + assert canceled.status_code == 200 and canceled.json()["status"]["state"] == expected + else: + assert canceled.status_code == 400 + assert canceled.json()["error"]["details"][0]["reason"] == "TASK_NOT_CANCELABLE" + if mode in {"refused", "settling"}: + assert "TASK_STATE_FAILED" not in stream_states(finished) + assert server.engine.sessions["ses_one"].subscription.error is None + assert "ses_one" in json.dumps(stored.json()["artifacts"]) + if mode in {"http-error", "timeout"}: + assert server.engine.sessions["ses_one"].subscription.error is not None + assert server.engine._active == 1 + + +@pytest.mark.asyncio +async def test_queued_cancel_closes_original_stream_without_sdk_lock_deadlock(boundary) -> None: + server, http = boundary + active_id, active = await server.start(http) + await asyncio.wait_for(server.admitted.wait(), 2) + queued_id, queued = await server.start(http) + canceled = await asyncio.wait_for(http.post(f"/tasks/{queued_id}:cancel", json={}), 2) + finished = await asyncio.wait_for(queued, 2) + assert canceled.status_code == 200 + assert stream_states(finished)[-1] == "TASK_STATE_CANCELED" + assert not active.done() and len(server.inputs) == 1 + assert not any(request.url.path.endswith("/interrupt") for request in server.requests) + lock = await server.app.state.agent_executor._session_manager.get_session_lock("ses_one") + assert lock.locked() + server.execution("ses_one", server.inputs[0]) + completed = await asyncio.wait_for(active, 2) + stored = await http.get(f"/tasks/{active_id}") + assert stream_states(completed)[-1] == "TASK_STATE_COMPLETED" + assert stored.json()["status"]["state"] == "TASK_STATE_COMPLETED" + assert not lock.locked() and server.engine._active == 0 diff --git a/tests/upstream/test_v2_interventions.py b/tests/upstream/test_v2_interventions.py new file mode 100644 index 00000000..598908b1 --- /dev/null +++ b/tests/upstream/test_v2_interventions.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from copy import deepcopy + +import httpx +import pytest +import pytest_asyncio + +from opencode_a2a.opencode_upstream_client import UpstreamContractError +from opencode_a2a.upstream.v2 import V2UpstreamAdapter +from opencode_a2a.upstream.v2_interventions import V2Interventions +from tests.support.settings import make_settings + + +@pytest_asyncio.fixture +async def adapter(tmp_path) -> AsyncIterator[tuple[V2UpstreamAdapter, V2Interventions, list]]: + client = V2UpstreamAdapter(make_settings(opencode_workspace_root=str(tmp_path))) + requests: list[httpx.Request] = [] + + async def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/api/info": + return httpx.Response(200, json={"version": "2.0.28"}) + return httpx.Response(204) + + client._client._transport = httpx.MockTransport(respond) + try: + yield client, V2Interventions(client), requests + finally: + await client.close() + + +def form(request: str = "frm_1", session: str = "ses_1", **field: object) -> dict: + return { + "type": "form.created", + "properties": { + "form": { + "id": request, + "sessionID": session, + "title": "Questions", + "metadata": {"kind": "question", "tool": {"messageID": "msg_1", "id": "call_1"}}, + "fields": [{"key": "q0", "type": "string", "title": "Name", **field}], + } + }, + } + + +def permission(request: str = "per_1", session: str = "ses_1") -> dict: + return { + "type": "permission.asked", + "properties": { + "id": request, + "sessionID": session, + "action": "read", + "resources": ["file.txt"], + "source": {"type": "tool", "messageID": "msg_1", "id": "call_1"}, + }, + } + + +async def bind( + client: V2UpstreamAdapter, + request: str = "frm_1", + session: str = "ses_1", + kind: str = "question", + **kwargs, +) -> None: + await client.remember_interrupt_request( + request_id=request, + session_id=session, + interrupt_type=kind, + identity="principal", + credential_id="credential", + task_id="task", + context_id="context", + **kwargs, + ) + + +@pytest.mark.asyncio +async def test_required_typed_string_form_uses_native_answer_and_204(adapter) -> None: + client, helper, requests = adapter + normalized = helper.normalize_event(form(required=True), session_id="ses_1") + assert normalized["type"] == "question.asked" + assert normalized["properties"]["tool"] == {"messageID": "msg_1", "callID": "call_1"} + await bind(client) + assert await helper.question_reply("frm_1", answers=[["typed answer"]]) is True + assert requests[-1].url.path == "/api/session/ses_1/form/frm_1/reply" + assert json.loads(requests[-1].content) == {"answer": {"q0": "typed answer"}} + assert (await client.resolve_interrupt_request("frm_1"))[0] == "missing" + assert not client.prompt_execution_supported + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reply", ["once", "reject"]) +async def test_permission_native_decision_and_source(adapter, reply) -> None: + client, helper, requests = adapter + event = helper.normalize_event(permission(), session_id="ses_1") + assert event["properties"]["permission"] == "read" + assert event["properties"]["patterns"] == ["file.txt"] + await bind(client, "per_1", kind="permission") + assert await helper.permission_reply("per_1", reply=reply, message="reason") is True + assert requests[-1].url.path == "/api/session/ses_1/permission/per_1/reply" + assert json.loads(requests[-1].content) == {"decision": reply, "message": "reason"} + + +@pytest.mark.asyncio +async def test_always_rejected_before_http(adapter) -> None: + client, helper, requests = adapter + helper.normalize_event(permission(), session_id="ses_1") + await bind(client, "per_1", kind="permission") + with pytest.raises(ValueError, match="only once or reject"): + await helper.permission_reply("per_1", reply="always") + assert requests == [] + assert (await client.resolve_interrupt_request("per_1"))[0] == "active" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "answers", + [[[True]], [[3]], [[None]], [[{}]], ["text"], "text", [["one", "two"]], [], [[]], [[""]]], +) +async def test_invalid_answers_never_reach_http(adapter, answers) -> None: + client, helper, requests = adapter + helper.normalize_event(form(required=True), session_id="ses_1") + await bind(client) + with pytest.raises(ValueError): + await helper.question_reply("frm_1", answers=answers) + assert requests == [] + assert (await client.resolve_interrupt_request("frm_1"))[0] == "active" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "field", + [ + {"type": "boolean"}, + {"type": "number"}, + {"type": "integer"}, + {"type": "external"}, + {"type": "unknown"}, + {"custom": "true"}, + {"options": [True]}, + {"when": []}, + {"minLength": 2}, + ], +) +async def test_unsupported_form_types_are_explicitly_rejected(adapter, field) -> None: + _, helper, requests = adapter + with pytest.raises(ValueError): + helper.normalize_event(form(**field), session_id="ses_1") + assert requests == [] + assert helper._pending == {} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("multiple", [False, True]) +async def test_options_custom_and_multiselect_match_native_types(adapter, multiple) -> None: + client, helper, requests = adapter + options = [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}] + helper.normalize_event( + form(type="multiselect" if multiple else "string", options=options), session_id="ses_1" + ) + await bind(client) + with pytest.raises(ValueError, match="declared question option"): + await helper.question_reply("frm_1", answers=[["custom"]]) + assert requests == [] + chosen = ["a", "b"] if multiple else ["a"] + await helper.question_reply("frm_1", answers=[chosen]) + assert json.loads(requests[-1].content) == {"answer": {"q0": chosen if multiple else "a"}} + + +@pytest.mark.asyncio +async def test_custom_text_is_not_coerced_or_trimmed(adapter) -> None: + client, helper, requests = adapter + helper.normalize_event( + form(options=[{"value": "a", "label": "A"}], custom=True), session_id="ses_1" + ) + await bind(client) + await helper.question_reply("frm_1", answers=[[" custom "]]) + assert json.loads(requests[-1].content) == {"answer": {"q0": " custom "}} + assert requests[-1].method == "POST" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", ["missing", "unobserved", "expired", "session", "type"]) +async def test_binding_required_before_get_reply_or_cancel(adapter, case) -> None: + client, helper, requests = adapter + if case != "unobserved": + helper.normalize_event(form(), session_id="ses_1") + if case != "missing": + await bind( + client, + session="ses_other" if case == "session" else "ses_1", + kind="permission" if case == "type" else "question", + ttl_seconds=0.001 if case == "expired" else 100, + ) + if case == "expired": + client._interrupt_request_clock = lambda: float("inf") + for operation in ( + helper.get("frm_1", interrupt_type="question"), + helper.question_reply("frm_1", answers=[["x"]]), + helper.question_reject("frm_1"), + ): + with pytest.raises(ValueError): + await operation + assert requests == [] + assert len(helper._pending) == (0 if case == "unobserved" else 1) + + +@pytest.mark.asyncio +async def test_question_cancel_uses_session_delete(adapter) -> None: + client, helper, requests = adapter + helper.normalize_event(form(), session_id="ses_1") + await bind(client) + assert await helper.question_reject("frm_1") is True + assert requests[-1].method == "DELETE" + assert requests[-1].url.path == "/api/session/ses_1/form/frm_1" + assert requests[-1].content == b"" + + +@pytest.mark.asyncio +async def test_concurrent_forms_keep_their_own_field_definitions(adapter) -> None: + client, helper, requests = adapter + helper.normalize_event(form("frm_a", "ses_a", required=True), session_id="ses_a") + helper.normalize_event( + form("frm_b", "ses_b", type="multiselect", options=[], custom=True), session_id="ses_b" + ) + await bind(client, "frm_a", "ses_a") + await bind(client, "frm_b", "ses_b") + await asyncio.gather( + helper.question_reply("frm_b", answers=[["b", "c"]]), + helper.question_reply("frm_a", answers=[["a"]]), + ) + payloads = {request.url.path: json.loads(request.content) for request in requests[1:]} + assert payloads == { + "/api/session/ses_a/form/frm_a/reply": {"answer": {"q0": "a"}}, + "/api/session/ses_b/form/frm_b/reply": {"answer": {"q0": ["b", "c"]}}, + } + for session, request in (("ses_a", "frm_a"), ("ses_b", "frm_b")): + resolved = helper.normalize_event( + {"type": "form.replied", "properties": {"sessionID": session, "id": request}}, + session_id=session, + ) + assert resolved is not None and resolved["type"] == "question.replied" + assert helper._pending == {} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "native,canonical", + [("form.replied", "question.replied"), ("form.cancelled", "question.rejected")], +) +async def test_resolution_is_exactly_session_bound_and_evicts_cache( + adapter, native, canonical +) -> None: + _, helper, requests = adapter + event = form() + helper.normalize_event(event, session_id="ses_1") + resolved = {"type": native, "properties": {"id": "frm_1", "sessionID": "ses_other"}} + assert helper.normalize_event(resolved, session_id="ses_1") is None + resolved["properties"]["sessionID"] = "ses_1" + assert helper.normalize_event(resolved, session_id="ses_1")["type"] == canonical + assert helper._pending == {} + assert requests == [] + assert event == form() + + +@pytest.mark.asyncio +async def test_get_uses_observed_owned_route_and_validates_response(adapter) -> None: + client, helper, requests = adapter + original = form() + helper.normalize_event(original, session_id="ses_1") + await bind(client) + detail = {**deepcopy(original["properties"]["form"]), "state": {"status": "pending"}} + + async def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, json={"version": "2.0.28"} if request.url.path == "/api/info" else {"data": detail} + ) + + client._client._transport = httpx.MockTransport(respond) + assert await helper.get("frm_1", interrupt_type="question") == detail + assert requests[-1].url.path == "/api/session/ses_1/form/frm_1" + detail["sessionID"] = "ses_other" + with pytest.raises(UpstreamContractError, match="mismatched ownership"): + await helper.get("frm_1", interrupt_type="question") + assert (await client.resolve_interrupt_request("frm_1"))[0] == "active" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [200, 404, 409, 500]) +async def test_failed_mutation_keeps_binding_until_callback_handles_error(adapter, status) -> None: + client, helper, requests = adapter + helper.normalize_event(form(), session_id="ses_1") + await bind(client) + + async def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/api/info": + return httpx.Response(200, json={"version": "2.0.28"}) + return httpx.Response(status, json={}) + + client._client._transport = httpx.MockTransport(respond) + with pytest.raises(UpstreamContractError if status == 200 else httpx.HTTPStatusError): + await helper.question_reply("frm_1", answers=[["a"]]) + assert (await client.resolve_interrupt_request("frm_1"))[0] == "active" + assert ("ses_1", "frm_1") in helper._pending + + +@pytest.mark.asyncio +async def test_permission_resolution_normalizes_request_id_and_removes_observation(adapter) -> None: + client, helper, requests = adapter + helper.normalize_event(permission(), session_id="ses_1") + await bind(client, "per_1", kind="permission") + event = { + "type": "permission.replied", + "properties": { + "sessionID": "ses_1", + "requestID": "per_1", + "reply": "reject", + }, + } + assert helper.normalize_event(event, session_id="ses_1") == event + with pytest.raises(ValueError, match="observed request"): + await helper.permission_reply("per_1", reply="once") + assert requests == [] + + +@pytest.mark.asyncio +async def test_other_sessions_are_ignored_and_generic_forms_explicitly_reject(adapter) -> None: + _, helper, requests = adapter + assert helper.normalize_event(form(), session_id="ses_other") is None + generic = form() + generic["properties"]["form"]["metadata"] = {"kind": "oauth"} + with pytest.raises(ValueError, match="Unsupported V2 form kind"): + helper.normalize_event(generic, session_id="ses_1") + assert helper._pending == {} + assert requests == [] + + +@pytest.mark.asyncio +async def test_event_field_cache_does_not_alias_source_or_normalized_payload(adapter) -> None: + client, helper, requests = adapter + event = form(options=[{"value": "a", "label": "A"}]) + normalized = helper.normalize_event(event, session_id="ses_1") + event["properties"]["form"]["fields"][0]["options"][0]["value"] = "source-mutated" + normalized["properties"]["questions"][0]["options"][0]["value"] = "output-mutated" + await bind(client) + await helper.question_reply("frm_1", answers=[["a"]]) + assert json.loads(requests[-1].content) == {"answer": {"q0": "a"}} + resolved = helper.normalize_event( + {"type": "form.replied", "properties": {"sessionID": "ses_1", "id": "frm_1"}}, + session_id="ses_1", + ) + assert resolved is not None and resolved["type"] == "question.replied" + assert helper._pending == {} + + +@pytest.mark.asyncio +async def test_network_error_preserves_pending_request_for_retry(adapter) -> None: + client, helper, requests = adapter + helper.normalize_event(form(), session_id="ses_1") + await bind(client) + + async def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/api/info": + return httpx.Response(200, json={"version": "2.0.28"}) + raise httpx.ConnectError("simulated upstream loss", request=request) + + client._client._transport = httpx.MockTransport(respond) + with pytest.raises(httpx.ConnectError): + await helper.question_reject("frm_1") + assert (await client.resolve_interrupt_request("frm_1"))[0] == "active" + assert ("ses_1", "frm_1") in helper._pending + + +@pytest.mark.asyncio +async def test_lost_execution_lease_rejects_callback_before_http(adapter) -> None: + client, _, requests = adapter + + def invalid_lease(session: str) -> None: + assert session == "ses_1" + raise ValueError("Observation is poisoned") + + helper = V2Interventions(client, validate_lease=invalid_lease) + helper.normalize_event(form(), session_id="ses_1") + await bind(client) + with pytest.raises(ValueError, match="poisoned"): + await helper.question_reply("frm_1", answers=[["Yes"]]) + assert requests == [] + + +@pytest.mark.asyncio +async def test_simultaneous_callbacks_make_only_one_native_mutation(adapter) -> None: + client, helper, requests = adapter + helper.normalize_event(form(custom=True), session_id="ses_1") + await bind(client) + results = await asyncio.gather( + helper.question_reply("frm_1", answers=[["Yes"]]), + helper.question_reply("frm_1", answers=[["Yes"]]), + return_exceptions=True, + ) + assert sum(result is True for result in results) == 1 + assert sum(isinstance(result, ValueError) for result in results) == 1 + assert sum(request.method == "POST" for request in requests) == 1 diff --git a/tests/upstream/test_v2_profile.py b/tests/upstream/test_v2_profile.py new file mode 100644 index 00000000..799a7a4f --- /dev/null +++ b/tests/upstream/test_v2_profile.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import pytest + +from opencode_a2a.opencode_upstream_client import UpstreamContractError +from opencode_a2a.upstream.v2_profile import ( + exclusive_permissions, + validate_exclusive_configuration, + validate_exclusive_settings, +) +from tests.support.settings import make_settings + + +def test_exclusive_profile_requires_private_separate_authenticated_upstream() -> None: + settings = make_settings() + with pytest.raises(ValueError, match="credentials"): + validate_exclusive_settings(settings) + settings.opencode_auth_password = "test-only-upstream" + settings.opencode_workspace_root = "/workspace" + validate_exclusive_settings(settings) + settings.opencode_base_url = "http://example.test:4096" + with pytest.raises(ValueError, match="loopback"): + validate_exclusive_settings(settings) + + +def test_background_and_custom_producers_are_rejected() -> None: + plugins = {"data": [{"source": {"type": "builtin"}}]} + validate_exclusive_configuration([{"type": "document", "info": {"warming": False}}], plugins) + for info in ( + {"warming": True}, + {"plugins": ["untrusted"]}, + {"mcp": {"servers": {"remote": {}}}}, + ): + with pytest.raises(UpstreamContractError): + validate_exclusive_configuration([{"type": "document", "info": info}], plugins) + with pytest.raises(UpstreamContractError, match="builtin"): + validate_exclusive_configuration([], {"data": [{"source": {"type": "local"}}]}) + permissions = exclusive_permissions() + assert permissions == [ + {"action": "*", "resource": "*", "effect": "deny"}, + {"action": "question", "resource": "*", "effect": "allow"}, + {"action": "read", "resource": "*", "effect": "ask"}, + {"action": "external_directory", "resource": "*", "effect": "deny"}, + ] + assert permissions[0] == {"action": "*", "resource": "*", "effect": "deny"} + assert permissions[-1] == {"action": "external_directory", "resource": "*", "effect": "deny"} diff --git a/tests/upstream/test_v2_stream_metadata.py b/tests/upstream/test_v2_stream_metadata.py new file mode 100644 index 00000000..658d13d2 --- /dev/null +++ b/tests/upstream/test_v2_stream_metadata.py @@ -0,0 +1,67 @@ +import asyncio + +import pytest + +from tests.execution.test_v2_executor_cancellation import ExecutorServer + + +@pytest.mark.asyncio +async def test_typed_multiselect_metadata_survives_shared_rendering_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = ExecutorServer(monkeypatch) + pending = None + updates = None + try: + session = await server.client.create_session() + pending = asyncio.create_task( + server.client.submit_prompt(session, "Ask a question", stream_updates_requested=True) + ) + await asyncio.wait_for(server.admitted.wait(), 1) + identity = server.inputs[0] + for event in [ + server.event( + session, "session.inbox.enqueued", inboxID=identity, item={"type": "user"} + ), + server.event(session, "session.inbox.delivered", inboxID=identity), + server.event(session, "session.step.started", assistantMessageID="msg_assistant"), + { + "id": "evt_form_multiple", + "type": "form.created", + "data": { + "form": { + "id": "frm_multiple", + "sessionID": session, + "metadata": { + "kind": "question", + "tool": {"messageID": "msg_assistant", "id": "tool_question"}, + }, + "fields": [ + { + "key": "q0", + "type": "multiselect", + "title": "Pick", + "options": [{"value": "Alpha", "label": "Alpha"}], + "custom": False, + } + ], + } + }, + }, + ]: + server.feed.send(event) + updates = server.client.stream_updates(session_id=session) + update = await asyncio.wait_for(anext(updates), 1) + assert update.intervention is not None + assert update.intervention.request_id == "frm_multiple" + questions = update.intervention.details["questions"] + assert questions[0]["multiple"] is True + assert questions[0]["custom"] is False + assert questions[0]["options"] == [{"value": "Alpha", "label": "Alpha"}] + finally: + if updates is not None: + await updates.aclose() + if pending is not None: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + await server.close() diff --git a/tests/upstream/test_v2_upstream_adapter.py b/tests/upstream/test_v2_upstream_adapter.py index 5b25636c..48d0bc43 100644 --- a/tests/upstream/test_v2_upstream_adapter.py +++ b/tests/upstream/test_v2_upstream_adapter.py @@ -220,14 +220,17 @@ async def respond(_request: httpx.Request) -> httpx.Response: client, requests = _make_client(monkeypatch, respond, workspace_root=str(workspace)) try: + assert client.prompt_execution_supported is False + assert "input ID identifies its" in client.prompt_execution_unsupported_reason + assert "task-scoped active cancellation" in client.prompt_execution_unsupported_reason with pytest.raises( UnsupportedUpstreamOperationError, - match="prompt execution is not implemented", + match="does not prove a per-input terminal outcome", ): await client.send_message("ses-1", "hello") with pytest.raises( UnsupportedUpstreamOperationError, - match="prompt execution is not implemented", + match="does not prove a per-input terminal outcome", ): await client.submit_prompt("ses-1", "hello") finally: From 6a907ae02c0fdac8cc159469d9da2dfbaedaf82d Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:06:04 +0100 Subject: [PATCH 12/30] test(v2): mark synthetic credentials and source digest as false positives --- .secrets.baseline | 12 +++++++++++- tests/contracts/test_v2_exclusive_capabilities.py | 2 +- tests/execution/test_v2_executor_cancellation.py | 2 +- tests/server/test_v2_cancel_http_boundary.py | 2 +- tests/upstream/test_v2_profile.py | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 2597b99a..8dd53ead 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -126,6 +126,16 @@ "path": "detect_secrets.filters.heuristic.is_templated_secret" } ], - "results": {}, + "results": { + "docs/evidence/v2-exclusive-acceptance.json": [ + { + "type": "Hex High Entropy String", + "filename": "docs/evidence/v2-exclusive-acceptance.json", + "hashed_secret": "4aadf55ac3817cd124386b39643072bc24649797", + "is_verified": false, + "line_number": 4 + } + ] + }, "generated_at": "2026-03-19T03:57:43Z" } diff --git a/tests/contracts/test_v2_exclusive_capabilities.py b/tests/contracts/test_v2_exclusive_capabilities.py index 29ade3b3..1d7cd8df 100644 --- a/tests/contracts/test_v2_exclusive_capabilities.py +++ b/tests/contracts/test_v2_exclusive_capabilities.py @@ -14,7 +14,7 @@ async def test_exclusive_discovery_and_unsupported_calls_never_reach_upstream() opencode_api_version="v2", opencode_v2_execution="exclusive", opencode_workspace_root="/workspace", - opencode_auth_password="synthetic-upstream-password", + opencode_auth_password="synthetic-upstream-password", # pragma: allowlist secret ) app = create_app(settings) requests: list[httpx.Request] = [] diff --git a/tests/execution/test_v2_executor_cancellation.py b/tests/execution/test_v2_executor_cancellation.py index 1e118617..5df16cc4 100644 --- a/tests/execution/test_v2_executor_cancellation.py +++ b/tests/execution/test_v2_executor_cancellation.py @@ -39,7 +39,7 @@ def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: opencode_api_version="v2", opencode_v2_execution="exclusive", opencode_workspace_root="/workspace", - opencode_auth_password="synthetic-test-password", + opencode_auth_password="synthetic-test-password", # pragma: allowlist secret ) ) monkeypatch.setattr(self.client._client, "_transport", httpx.MockTransport(self.handle)) diff --git a/tests/server/test_v2_cancel_http_boundary.py b/tests/server/test_v2_cancel_http_boundary.py index 9465d40f..344e6a18 100644 --- a/tests/server/test_v2_cancel_http_boundary.py +++ b/tests/server/test_v2_cancel_http_boundary.py @@ -23,7 +23,7 @@ def __init__(self, tmp_path: Path) -> None: opencode_api_version="v2", opencode_v2_execution="exclusive", opencode_workspace_root="/workspace", - opencode_auth_password="synthetic-test-password", + opencode_auth_password="synthetic-test-password", # pragma: allowlist secret a2a_task_store_backend="database", a2a_task_store_database_url=f"sqlite+aiosqlite:///{tmp_path / 'tasks.db'}", a2a_cancel_abort_timeout_seconds=0.1, diff --git a/tests/upstream/test_v2_profile.py b/tests/upstream/test_v2_profile.py index 799a7a4f..5ddd5548 100644 --- a/tests/upstream/test_v2_profile.py +++ b/tests/upstream/test_v2_profile.py @@ -15,7 +15,7 @@ def test_exclusive_profile_requires_private_separate_authenticated_upstream() -> settings = make_settings() with pytest.raises(ValueError, match="credentials"): validate_exclusive_settings(settings) - settings.opencode_auth_password = "test-only-upstream" + settings.opencode_auth_password = "test-only-upstream" # pragma: allowlist secret settings.opencode_workspace_root = "/workspace" validate_exclusive_settings(settings) settings.opencode_base_url = "http://example.test:4096" From 16e99ea84a5f9e1f10b3cd7d17c35e0ac3146fb9 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:06:29 +0100 Subject: [PATCH 13/30] test(v2): mark second synthetic upstream password fixture --- tests/contracts/test_v2_exclusive_capabilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contracts/test_v2_exclusive_capabilities.py b/tests/contracts/test_v2_exclusive_capabilities.py index 1d7cd8df..3991ac66 100644 --- a/tests/contracts/test_v2_exclusive_capabilities.py +++ b/tests/contracts/test_v2_exclusive_capabilities.py @@ -82,7 +82,7 @@ async def test_exclusive_model_and_workspace_overrides_fail_before_upstream(meta opencode_api_version="v2", opencode_v2_execution="exclusive", opencode_workspace_root="/workspace", - opencode_auth_password="synthetic-upstream-password", + opencode_auth_password="synthetic-upstream-password", # pragma: allowlist secret ) ) requests: list[httpx.Request] = [] From 6471dbb1fe58710d2385525fd54851bd724a69cf Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:02:07 +0100 Subject: [PATCH 14/30] test(compatibility): add isolated V2 acceptance and fault probes (#6) --- .gitignore | 4 + pyproject.toml | 2 + scripts/README.md | 2 + scripts/live_opencode_smoke.sh | 45 +++- scripts/live_opencode_v2_exclusive_probe.sh | 98 +++++++ scripts/live_opencode_v2_session_smoke.sh | 255 +++++++++++++++++++ scripts/probes/v2_a2a.mjs | 267 ++++++++++++++++++++ scripts/probes/v2_exclusive.mjs | 171 +++++++++++++ scripts/probes/v2_fault_proxy.mjs | 86 +++++++ 9 files changed, 919 insertions(+), 11 deletions(-) create mode 100644 scripts/live_opencode_v2_exclusive_probe.sh create mode 100755 scripts/live_opencode_v2_session_smoke.sh create mode 100644 scripts/probes/v2_a2a.mjs create mode 100644 scripts/probes/v2_exclusive.mjs create mode 100644 scripts/probes/v2_fault_proxy.mjs diff --git a/.gitignore b/.gitignore index 65fdb0c5..e4338525 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ pip-delete-this-directory.txt # Local logs logs/ run/ +.opencode/a2a/state/ # Unit test / coverage reports htmlcov/ @@ -200,6 +201,9 @@ cython_debug/ # Ruff stuff: .ruff_cache/ +# Isolated issue-scoped agent worktrees +.worktrees/ + # PyPI configuration file .pypirc diff --git a/pyproject.toml b/pyproject.toml index 9b08b09b..873aa6ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ Issues = "https://github.com/Intelligent-Internet/opencode-a2a/issues" [tool.ruff] line-length = 100 src = ["src"] +extend-exclude = [".worktrees"] [tool.ruff.lint] select = ["E", "F", "I", "B", "UP", "S"] @@ -116,6 +117,7 @@ ignore_missing_imports = true [tool.pytest.ini_options] addopts = "--cov=src/opencode_a2a --cov-report=term-missing --cov-report=json:.coverage.json --cov-fail-under=90" +norecursedirs = [".worktrees"] filterwarnings = [ "error::pytest.PytestUnhandledThreadExceptionWarning", "error::pytest.PytestUnraisableExceptionWarning", diff --git a/scripts/README.md b/scripts/README.md index 802ad640..25154320 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,6 +14,8 @@ Executable scripts live in this directory. This file is the entry index for the - [`doctor.sh`](./doctor.sh): primary local development regression entrypoint with explicit fix/verify/package phases (uv sync + dependency compatibility + lint + mypy + tests + coverage + hash-locked PEP 517 build + built-wheel smoke test) - [`conformance.sh`](./conformance.sh): repository-owned A2A 1.0 black-box compatibility probes; can launch a dummy-backed local SUT and preserves machine-readable artifacts under `run/conformance/` - [`live_opencode_smoke.sh`](./live_opencode_smoke.sh): local/manual live integration smoke against a real OpenCode runtime; boots an isolated password-hardened `opencode serve` plus the opencode-a2a runtime in throwaway directories and verifies upstream auth, JSON-RPC extension reads, and a streaming prompt round-trip +- [`live_opencode_v2_session_smoke.sh`](./live_opencode_v2_session_smoke.sh): pinned OpenCode 2.0.28 session-transport smoke; starts an isolated password-hardened server and exercises the application-selected V2 adapter's identity, location, and session create/get/list routes without submitting a model prompt +- [`live_opencode_v2_exclusive_probe.sh`](./live_opencode_v2_exclusive_probe.sh): disposable Docker namespace proof and optional real-model A2A exclusive-profile acceptance, including test-only fault injection; see [scope, prerequisites, and commands](../docs/v2-exclusive-execution.md#reproduce-the-live-evidence) - [`dependency_health.sh`](./dependency_health.sh): development and build dependency review entrypoint (`sync`/`pip check` + outdated + strict hash-locked audits), while blocking CI/publish audits focus on runtime dependencies - [`check_coverage.py`](./check_coverage.py): enforces the overall coverage floor and per-file minimums for critical modules - [`find_thin_wrappers.py`](./find_thin_wrappers.py): static analysis helper that reports local functions with low caller counts and flags likely thin forwarding wrappers for manual abstraction review diff --git a/scripts/live_opencode_smoke.sh b/scripts/live_opencode_smoke.sh index e33c227f..57b0b9e5 100755 --- a/scripts/live_opencode_smoke.sh +++ b/scripts/live_opencode_smoke.sh @@ -110,11 +110,16 @@ a2a_port="${LIVE_SMOKE_A2A_PORT:-$(free_port)}" upstream_url="http://127.0.0.1:${upstream_port}" a2a_url="http://127.0.0.1:${a2a_port}" upstream_username="opencode" -# Non-secret throwaway credential for the isolated smoke instance only. -upstream_password="live-smoke-password" # pragma: allowlist secret -a2a_bearer_token="live-smoke-token" # pragma: allowlist secret - -run_dir="$(mktemp -d)" +upstream_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" +a2a_bearer_token="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + +umask 077 +mkdir -p /tmp/opencode +run_dir="$(mktemp -d /tmp/opencode/v1-live-smoke.XXXXXX)" +upstream_curl_config="${run_dir}/upstream-curl.conf" +a2a_curl_config="${run_dir}/a2a-curl.conf" +printf 'user = "%s:%s"\n' "${upstream_username}" "${upstream_password}" >"${upstream_curl_config}" +printf 'header = "Authorization: Bearer %s"\n' "${a2a_bearer_token}" >"${a2a_curl_config}" opencode_home="${run_dir}/opencode-home" workspace_dir="${run_dir}/workspace" adapter_dir="${run_dir}/adapter" @@ -134,6 +139,7 @@ cleanup() { wait "${upstream_pid}" >/dev/null 2>&1 || true fi if [[ "${LIVE_SMOKE_KEEP_ARTIFACTS:-0}" == "1" ]]; then + rm -f "${upstream_curl_config}" "${a2a_curl_config}" echo "[live-smoke] artifacts kept at ${run_dir}" else rm -rf "${run_dir}" @@ -168,7 +174,7 @@ for _ in $(seq 1 60); do cat "${upstream_log}" >&2 exit 1 fi - if curl -fsS --max-time 5 -u "${upstream_username}:${upstream_password}" \ + if curl --config "${upstream_curl_config}" -fsS --max-time 5 \ "${upstream_url}/doc" >/dev/null 2>&1; then upstream_ready="1" break @@ -208,7 +214,7 @@ for _ in $(seq 1 60); do cat "${adapter_log}" >&2 exit 1 fi - if curl -fsS --max-time 5 -H "Authorization: Bearer ${a2a_bearer_token}" \ + if curl --config "${a2a_curl_config}" -fsS --max-time 5 \ "${a2a_url}/health" >/dev/null 2>&1; then a2a_ready="1" break @@ -222,12 +228,11 @@ if [[ -z "${a2a_ready}" ]]; then fi echo "[live-smoke] adapter ready" -auth_header="Authorization: Bearer ${a2a_bearer_token}" extension_header="A2A-Extensions: urn:opencode-a2a:extension:session-management:v1, urn:opencode-a2a:extension:workspace-control:v1, urn:opencode-a2a:extension:provider-discovery:v1" jsonrpc_probe() { local payload="$1" - curl -sS --max-time 30 -H "${auth_header}" -H "${extension_header}" \ + curl --config "${a2a_curl_config}" -sS --max-time 30 -H "${extension_header}" \ -H "Content-Type: application/json" \ -d "${payload}" "${a2a_url}/" } @@ -264,7 +269,7 @@ echo "[live-smoke] PASS: opencode.sessions.status" if [[ "${LIVE_SMOKE_SKIP_PROMPT:-0}" != "1" ]]; then echo "[live-smoke] probe: streaming prompt round-trip" curl -sS -N \ - -H "${auth_header}" \ + --config "${a2a_curl_config}" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":10,"method":"SendStreamingMessage","params":{"message":{"messageId":"live-smoke-msg","role":"ROLE_USER","parts":[{"text":"Reply with exactly: smoke-ok"}]}}}' \ "${a2a_url}/" --max-time 180 >"${stream_result}" @@ -273,7 +278,25 @@ if [[ "${LIVE_SMOKE_SKIP_PROMPT:-0}" != "1" ]]; then tail -c 2000 "${stream_result}" >&2 exit 1 fi - if ! grep -q 'smoke-ok' "${stream_result}"; then + if ! python3 - "${stream_result}" <<'PY' +import json +import sys +from pathlib import Path + +blocks = {} +for line in Path(sys.argv[1]).read_text().splitlines(): + if not line.startswith("data:"): + continue + event = json.loads(line[5:]).get("result", {}).get("artifactUpdate", {}) + artifact = event.get("artifact", {}) + identity = artifact.get("artifactId") + if identity is None: + continue + text = "".join(part.get("text", "") for part in artifact.get("parts", [])) + blocks[identity] = blocks.get(identity, "") + text if event.get("append") else text +raise SystemExit(0 if "smoke-ok" in "\n".join(blocks.values()) else 1) +PY + then echo "[live-smoke] FAIL: streaming prompt reply did not contain the expected text" >&2 tail -c 2000 "${stream_result}" >&2 exit 1 diff --git a/scripts/live_opencode_v2_exclusive_probe.sh b/scripts/live_opencode_v2_exclusive_probe.sh new file mode 100644 index 00000000..5fbcf713 --- /dev/null +++ b/scripts/live_opencode_v2_exclusive_probe.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +binary="${OPENCODE_BIN:-$(command -v opencode)}" +[[ "$("${binary}" --version)" == "opencode v2.0.28" ]] || { echo 'OpenCode v2.0.28 required' >&2; exit 1; } +[[ -n "${OPENAI_API_KEY:-}" ]] || { echo 'OPENAI_API_KEY required (not printed)' >&2; exit 1; } +image="${V2_PROBE_IMAGE:-node:22-bookworm-slim}" +docker image inspect "${image}" >/dev/null +run="$(mktemp -d /tmp/opencode/v2-exclusive.XXXXXX)" +chmod 700 "${run}" +umask 077 +container="" +cleanup() { + local result="$?" + if [[ -n "${container}" ]]; then + if [[ "${result}" != 0 ]]; then docker logs "${container}" > "${run}/server.log" 2>&1; fi + docker rm -f "${container}" >/dev/null + fi + rm -f "${run}/key" "${run}/password" "${run}/a2a-token" "${run}/other-token" + if [[ "${result}" == 0 ]]; then rm -rf "${run}"; fi + exit "${result}" +} +trap cleanup EXIT +mkdir -p "${run}/workspace" "${run}/home" +printf '%s' 'SAFE_FILE_CONTENT' > "${run}/workspace/acceptance.txt" +mkdir -p "${run}/home/.config/opencode" +printf '%s' '{"model":"openai/gpt-6-luna-fast","warming":false,"experimental":{"subagent_depth":0}}' > "${run}/home/.config/opencode/opencode.json" +printf '%s' "${OPENAI_API_KEY}" > "${run}/key" +if [[ "${V2_PROBE_CASE:-normal}" == "provider-failure" ]]; then + printf '%s' 'intentionally-invalid-test-provider-key' > "${run}/key" +fi +python3 -c 'import secrets; print(secrets.token_urlsafe(32), end="")' > "${run}/password" +python3 -c 'import secrets; print(secrets.token_urlsafe(32), end="")' > "${run}/a2a-token" +python3 -c 'import secrets; print(secrets.token_urlsafe(32), end="")' > "${run}/other-token" +container="$(docker run -d --user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=${binary},target=/usr/local/bin/opencode,readonly" \ + --mount "type=bind,source=${run},target=/probe" \ + --mount "type=bind,source=${root},target=/repo" \ + --mount "type=bind,source=$(command -v uv),target=/usr/local/bin/uv,readonly" \ + --workdir /probe/workspace \ + --env HOME=/probe/home --env XDG_CONFIG_HOME=/probe/home/.config \ + --env XDG_DATA_HOME=/probe/home/.local/share --env XDG_CACHE_HOME=/probe/home/.cache \ + "${image}" sh -c ' + export OPENAI_API_KEY="$(cat /probe/key)" + export OPENCODE_SERVER_PASSWORD="$(cat /probe/password)" + export OPENCODE_SERVER_USERNAME=opencode + exec opencode serve --hostname 127.0.0.1 --port 4096 + ')" +echo "[exclusive-probe] private runtime ${run}" >&2 +docker exec -i "${container}" node --input-type=module <<'JS' +import { readFile } from "node:fs/promises"; +import assert from "node:assert/strict"; +const password = (await readFile("/probe/password", "utf8")).trim(); +const headers = { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }; +let ready = false; +for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await fetch("http://127.0.0.1:4096/api/info", { headers, signal: AbortSignal.timeout(1000) }); + if (response.ok && (await response.json()).version === "2.0.28") { ready = true; break; } + } catch (error) { if (error.name !== "TypeError" && error.name !== "TimeoutError") throw error; } + await new Promise(resolve => setTimeout(resolve, 200)); +} +assert.ok(ready, "private pinned upstream did not become ready"); +JS +# No published port. A peer in the same bridge still cannot reach loopback. +address="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${container}")" +python3 - "${address}" <<'PY' +import socket +import sys +with socket.socket() as connection: + connection.settimeout(2) + if connection.connect_ex((sys.argv[1], 4096)) == 0: + raise SystemExit("Host process reached the private upstream listener") +PY +docker run --rm node:22-bookworm-slim node --input-type=module -e ' + try { await fetch(`http://${process.argv[1]}:4096/api/info`, {signal:AbortSignal.timeout(2000)}); process.exit(1); } + catch { console.log("[exclusive-probe] independent network peer cannot reach upstream listener"); } +' "${address}" >&2 +if [[ "${V2_PROBE_NATIVE:-1}" == 1 ]]; then + docker exec -i "${container}" node --input-type=module < "${root}/scripts/probes/v2_exclusive.mjs" +fi +if [[ "${V2_PROBE_A2A:-0}" == 1 ]]; then + docker exec -d --env "V2_PROBE_CASE=${V2_PROBE_CASE:-normal}" "${container}" \ + node /repo/scripts/probes/v2_fault_proxy.mjs + docker exec -d --env "V2_PROBE_CASE=${V2_PROBE_CASE:-normal}" "${container}" sh -c ' + export UV_PROJECT_ENVIRONMENT=/probe/venv UV_CACHE_DIR=/probe/uv-cache + export OPENCODE_API_VERSION=v2 OPENCODE_V2_EXECUTION=exclusive + export OPENCODE_BASE_URL=http://127.0.0.1:4097 OPENCODE_WORKSPACE_ROOT=/probe/workspace + export OPENCODE_TIMEOUT_STREAM=30 + if [ "${V2_PROBE_CASE}" = expired-intervention ]; then export A2A_INTERRUPT_REQUEST_TTL_SECONDS=1; fi + export OPENCODE_AUTH_PASSWORD="$(cat /probe/password)" + export A2A_STATIC_AUTH_CREDENTIALS="[{\"scheme\":\"bearer\",\"token\":\"$(cat /probe/a2a-token)\",\"principal\":\"probe\"},{\"scheme\":\"bearer\",\"token\":\"$(cat /probe/other-token)\",\"principal\":\"other\"}]" + export A2A_HOST=127.0.0.1 A2A_PORT=8000 A2A_PUBLIC_URL=http://127.0.0.1:8000 + export A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:////probe/a2a.db + exec uv run --project /repo --frozen opencode-a2a serve >/probe/gateway.log 2>&1 + ' + docker exec -i --env "V2_PROBE_CASE=${V2_PROBE_CASE:-normal}" "${container}" node --input-type=module < "${root}/scripts/probes/v2_a2a.mjs" +fi diff --git a/scripts/live_opencode_v2_session_smoke.sh b/scripts/live_opencode_v2_session_smoke.sh new file mode 100755 index 00000000..a2423f01 --- /dev/null +++ b/scripts/live_opencode_v2_session_smoke.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + bash ./scripts/live_opencode_v2_session_smoke.sh + +Purpose: + Start an isolated, password-protected OpenCode 2.0.28 server and exercise + the application-selected V2 adapter's /api/info, /api/location, and session + create/get/list routes. This smoke does not submit a model prompt. + +Optional environment: + OPENCODE_BIN OpenCode executable (default: opencode from PATH) +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +if [[ "$#" -gt 0 ]]; then + echo "Expected no positional arguments" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +opencode_bin="${OPENCODE_BIN:-opencode}" +if ! command -v "${opencode_bin}" >/dev/null 2>&1; then + echo "OpenCode CLI not found: ${opencode_bin}" >&2 + exit 1 +fi +for required_command in curl python3 uv; do + if ! command -v "${required_command}" >/dev/null 2>&1; then + echo "${required_command} not found in PATH" >&2 + exit 1 + fi +done + +opencode_version="$("${opencode_bin}" --version 2>/dev/null | tr -d '[:space:]')" +if [[ "${opencode_version}" != "opencodev2.0.28" ]]; then + echo "Expected OpenCode 2.0.28; found ${opencode_version}" >&2 + exit 1 +fi + +free_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +run_dir="$(mktemp -d "${TMPDIR:-/tmp}/opencode-a2a-v2-session.XXXXXX")" +workspace_dir="${run_dir}/workspace" +home_dir="${run_dir}/home" +mkdir -p "${workspace_dir}" "${home_dir}/.config" "${home_dir}/.local/share" "${home_dir}/.cache" +adapter_home="${run_dir}/adapter-home" +mkdir -p "${adapter_home}/.config" "${adapter_home}/.cache" "${run_dir}/uv-cache" +upstream_port="$(free_port)" +upstream_url="http://127.0.0.1:${upstream_port}" +upstream_username="opencode" +read -r upstream_password inbound_token < <( + python3 -c 'import secrets; print(secrets.token_urlsafe(32), secrets.token_urlsafe(32))' +) +upstream_log="${run_dir}/opencode.log" +credentials_file="${run_dir}/smoke-credentials.json" +server_password_file="${run_dir}/server-password" +curl_auth_config="${run_dir}/curl-auth.conf" +curl_invalid_auth_config="${run_dir}/curl-invalid-auth.conf" +server_pid="" +umask 077 +printf '%s' "${upstream_password}" >"${server_password_file}" +printf '%s\0' "${upstream_url}" "${workspace_dir}" "${upstream_username}" \ + "${upstream_password}" "${inbound_token}" | + python3 -c ' +import json +import os +import sys +from pathlib import Path + +values = sys.stdin.buffer.read().split(b"\0") +if values[-1] == b"": + values.pop() +if len(values) != 5: + raise RuntimeError("Expected five V2 smoke configuration values") +configuration = dict( + zip( + ("base_url", "workspace", "username", "upstream_password", "inbound_token"), + (value.decode("utf-8") for value in values), + strict=True, + ) +) +path = Path(sys.argv[1]) +with path.open("x", encoding="utf-8") as credentials: + json.dump(configuration, credentials) + credentials.flush() + os.fchmod(credentials.fileno(), 0o600) +' \ + "${credentials_file}" +python3 - "${credentials_file}" "${curl_auth_config}" "${curl_invalid_auth_config}" <<'PY' +import json +import os +import sys +from pathlib import Path + +configuration = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +auth_settings = ( + (Path(sys.argv[2]), f"user = \"{configuration['username']}:{configuration['upstream_password']}\"\n"), + ( + Path(sys.argv[3]), + f"user = \"{configuration['username']}:invalid-{configuration['inbound_token']}\"\n", + ), +) +for path, contents in auth_settings: + with path.open("x", encoding="utf-8") as auth_file: + auth_file.write(contents) + auth_file.flush() + os.fchmod(auth_file.fileno(), 0o600) +PY + +cleanup() { + local exit_code="$1" + if [[ -n "${server_pid}" ]] && kill -0 "${server_pid}" >/dev/null 2>&1; then + kill "${server_pid}" >/dev/null 2>&1 || true + wait "${server_pid}" >/dev/null 2>&1 || true + fi + rm -rf "${run_dir}" + exit "${exit_code}" +} +trap 'cleanup $?' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +echo "[v2-session-smoke] starting OpenCode 2.0.28 on an ephemeral localhost port" +env -i \ + PATH="${PATH}" \ + HOME="${home_dir}" \ + XDG_CONFIG_HOME="${home_dir}/.config" \ + XDG_DATA_HOME="${home_dir}/.local/share" \ + XDG_CACHE_HOME="${home_dir}/.cache" \ + TMPDIR="${run_dir}" \ + SERVER_PASSWORD_FILE="${server_password_file}" \ + OPENCODE_SERVER_USERNAME="${upstream_username}" \ + bash -c ' + export OPENCODE_SERVER_PASSWORD="$(<"${SERVER_PASSWORD_FILE}")" + exec "$@" + ' _ "${opencode_bin}" serve --hostname 127.0.0.1 --port "${upstream_port}" \ + >"${upstream_log}" 2>&1 & +server_pid="$!" + +upstream_ready="" +for _ in $(seq 1 60); do + if ! kill -0 "${server_pid}" >/dev/null 2>&1; then + echo "[v2-session-smoke] OpenCode exited before becoming ready" >&2 + exit 1 + fi + if curl --config "${curl_auth_config}" -fsS --max-time 2 \ + "${upstream_url}/api/info" >/dev/null 2>&1; then + upstream_ready="1" + break + fi + sleep 0.25 +done +if [[ -z "${upstream_ready}" ]]; then + echo "[v2-session-smoke] OpenCode did not become ready" >&2 + exit 1 +fi + +auth_status="$(curl --config "${curl_invalid_auth_config}" -sS --max-time 2 \ + -o /dev/null -w '%{http_code}' "${upstream_url}/api/info")" +if [[ "${auth_status}" != "401" ]]; then + echo "[v2-session-smoke] expected invalid credentials to return 401; got ${auth_status}" >&2 + exit 1 +fi + +env -i \ + PATH="${PATH}" \ + HOME="${adapter_home}" \ + XDG_CONFIG_HOME="${adapter_home}/.config" \ + XDG_CACHE_HOME="${adapter_home}/.cache" \ + TMPDIR="${run_dir}" \ + UV_CACHE_DIR="${run_dir}/uv-cache" \ + UV_PROJECT_ENVIRONMENT="${run_dir}/.venv" \ + V2_SMOKE_CREDENTIALS_FILE="${credentials_file}" \ + uv run --project "${ROOT_DIR}" python - <<'PY' +import asyncio +import json +import os +from pathlib import Path + +from opencode_a2a.config import Settings, StaticAuthCredentialSettings +from opencode_a2a.server.application import create_app +from opencode_a2a.upstream.v2 import V2UpstreamAdapter + + +async def main() -> None: + credentials = json.loads( + Path(os.environ["V2_SMOKE_CREDENTIALS_FILE"]).read_text(encoding="utf-8") + ) + workspace = Path(credentials["workspace"]) + settings = Settings( + _env_file=None, + opencode_api_version="v2", + opencode_base_url=credentials["base_url"], + opencode_workspace_root=str(workspace), + opencode_auth_username=credentials["username"], + opencode_auth_password=credentials["upstream_password"], + a2a_task_store_backend="memory", + a2a_static_auth_credentials=( + StaticAuthCredentialSettings( + scheme="bearer", + token=credentials["inbound_token"], + principal="v2-session-smoke", + ), + ), + ) + app = create_app(settings) + upstream = app.state.upstream_client + if not isinstance(upstream, V2UpstreamAdapter): + raise RuntimeError("Application did not select the V2 upstream adapter") + + try: + server_info = await upstream.get_server_info() + if server_info.get("version") != "2.0.28": + raise RuntimeError("V2 /api/info reported an unexpected version") + location = await upstream.get_location(directory=str(workspace)) + session_id = await upstream.create_session("V2 session smoke", directory=str(workspace)) + session = await upstream.get_session(session_id, directory=str(workspace)) + sessions = await upstream.list_sessions( + directory=str(workspace), + params={"limit": 5}, + ) + if location.get("directory") != str(workspace): + raise RuntimeError("V2 location did not preserve the selected workspace") + if session.get("id") != session_id: + raise RuntimeError("V2 session lookup returned a different session") + if not any(item.get("id") == session_id for item in sessions.get("data", [])): + raise RuntimeError("V2 session list did not include the created session") + finally: + await upstream.close() + await app.state.a2a_client_manager.close_all() + + +asyncio.run(main()) +PY + +echo "[v2-session-smoke] Basic-auth rejection: passed" +echo "[v2-session-smoke] /api/info, /api/location, session create/get/list: passed" +echo "[v2-session-smoke] no model prompt was submitted" diff --git a/scripts/probes/v2_a2a.mjs b/scripts/probes/v2_a2a.mjs new file mode 100644 index 00000000..139d429e --- /dev/null +++ b/scripts/probes/v2_a2a.mjs @@ -0,0 +1,267 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +let stage = "startup"; +const blockTypes = new Set(); +process.on("uncaughtException", error => { + console.error(`A2A probe failed at ${stage}: ${error.name}: ${error.message}`); + process.exit(1); +}); + +const token = (await readFile("/probe/a2a-token", "utf8")).trim(); +const otherToken = (await readFile("/probe/other-token", "utf8")).trim(); +const base = "http://127.0.0.1:8000"; +const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "A2A-Extensions": "urn:opencode-a2a:extension:session-binding:v1,urn:opencode-a2a:extension:stream-hints:v1,urn:opencode-a2a:extension:interactive-interrupt:v1" }; +let ready = false; +for (let attempt = 0; attempt < 240; attempt++) { + try { + const response = await fetch(base + "/.well-known/agent-card.json", { headers, signal: AbortSignal.timeout(2000) }); + if (response.ok) { ready = true; break; } + } catch (error) { if (error.name !== "TypeError" && error.name !== "TimeoutError") throw error; } + await new Promise(resolve => setTimeout(resolve, 500)); +} +assert.ok(ready, "gateway did not become ready; inspect private gateway log"); +async function rpc(method, params, streaming = false, onEvent, authToken = token) { + const response = await fetch(base + "/", { + method: "POST", headers: { ...headers, Authorization: `Bearer ${authToken}`, Accept: streaming ? "text/event-stream" : "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: randomUUID(), method, params }), + signal: AbortSignal.timeout(180000), + }); + assert.equal(response.status, 200); + if (!streaming) return response.json(); + const events = []; + let buffer = ""; + const decoder = new TextDecoder(); + for await (const chunk of response.body) { + buffer += decoder.decode(chunk, { stream: true }); + let boundary; + while ((boundary = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, boundary).trimEnd(); + buffer = buffer.slice(boundary + 1); + if (!line.startsWith("data:")) continue; + const event = JSON.parse(line.slice(5)); + events.push(event); + const block = event.result?.artifactUpdate?.artifact?.metadata?.shared?.stream?.block_type; + if (block) blockTypes.add(block); + if (onEvent) await onEvent(event); + } + } + return events; +} +function message(text, contextId) { + return { message: { messageId: randomUUID(), ...(contextId ? { contextId } : {}), role: "ROLE_USER", parts: [{ text }] } }; +} +function content(value) { + if (Array.isArray(value)) return value.map(content).join("\n"); + if (!value || typeof value !== "object") return ""; + return (typeof value.text === "string" ? value.text : "") + Object.entries(value).filter(([key]) => key !== "text").map(([, child]) => content(child)).join("\n"); +} +function artifacts(events) { + const blocks = new Map(); + for (const event of events) { + const update = event.result?.artifactUpdate; + if (!update?.artifact) continue; + const key = update.artifact.artifactId; + const text = content(update.artifact.parts); + blocks.set(key, update.append ? (blocks.get(key) ?? "") + text : text); + } + return [...blocks.values()].join("\n"); +} +const mode = process.env.V2_PROBE_CASE ?? "normal"; +async function evidence() { + const response = await fetch("http://127.0.0.1:4097/__probe/evidence", { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(2000) }); + assert.equal(response.status, 200); + return response.json(); +} +if (mode !== "normal") { + let pendingIntervention; + const negativePrompt = ["intervention-gap", "expired-intervention"].includes(mode) + ? 'Call the question tool once, ask "Proceed?" with Yes/No choices and wait for my answer.' + : "Reply exactly NEGATIVE_TEST. Do not use tools."; + const failed = await rpc("SendStreamingMessage", message(negativePrompt), true, async event => { + const update = event.result?.statusUpdate; + if (!["intervention-gap", "expired-intervention"].includes(mode) || update?.status?.state !== "TASK_STATE_INPUT_REQUIRED") return; + pendingIntervention = update.metadata.shared.interrupt.request_id; + if (mode === "expired-intervention") { + await new Promise(resolve => setTimeout(resolve, 1500)); + const expired = await rpc("a2a.interrupt.question.reply", { request_id: pendingIntervention, answers: [["Yes"]] }); + assert.ok(expired.error, "expired intervention accepted"); + return; + } + const disconnected = await fetch("http://127.0.0.1:4097/__probe/disconnect", { method: "POST", headers: { Authorization: `Bearer ${token}` } }); + assert.equal(disconnected.status, 200); + }); + assert.ok(failed.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_FAILED")); + assert.ok(!failed.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_COMPLETED")); + const observed = await evidence(); + assert.equal(observed.admissions, 1, "uncertain request was resubmitted"); + assert.equal(observed.event_connections, 1, "unexpected SSE replay/reconnect"); + if (observed.model_refs.length) assert.deepEqual(observed.model_refs, ["openai/gpt-6-luna-fast"]); + if (!["provider-failure", "expired-intervention"].includes(mode)) assert.equal(observed.injected, true); + if (mode === "provider-failure") assert.ok(observed.terminal_types.includes("session.execution.failed")); + if (["intervention-gap", "expired-intervention"].includes(mode)) { + assert.ok(pendingIntervention); + const late = await rpc("a2a.interrupt.question.reply", { request_id: pendingIntervention, answers: [["Yes"]] }); + assert.ok(late.error, "poisoned intervention resumed upstream execution"); + assert.equal((await evidence()).routes["POST /api/session/ID/form/ID/reply"] ?? 0, 0); + } + console.log(JSON.stringify({ mode, no_false_success: true, no_resubmission: true, upstream: observed }, null, 2)); + process.exit(0); +} +const marker = `a2a-exclusive-${randomUUID()}`; +stage = "stream"; +const stream = await rpc("SendStreamingMessage", message(`Remember ${marker}. Reply exactly ${marker}. Do not use tools.`), true); +assert.ok(stream.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_COMPLETED"), "stream has no proven completion"); +assert.ok(artifacts(stream).includes(marker), "stream has no expected assistant output"); +assert.ok(!stream.some(event => event.error), "stream returned protocol error"); +const task = stream.find(event => event.result?.task)?.result.task; +assert.ok(task?.id && task.contextId); +stage = "followup"; +const followup = await rpc("SendMessage", message("Reply with the remembered token only. Do not use tools.", task.contextId)); +const followupTask = followup.result?.task ?? followup.result; +if (followupTask.status?.state !== "TASK_STATE_COMPLETED") { + console.error(JSON.stringify({ state: followupTask.status?.state })); +} +assert.equal(followupTask.status?.state, "TASK_STATE_COMPLETED", "unary did not await terminal execution"); +assert.ok(content(followupTask.artifacts).includes(marker), "follow-up lost context"); +assert.notEqual(followupTask.id, task.id); +const persisted = await rpc("GetTask", { id: task.id }); +assert.equal(persisted.result?.status?.state, "TASK_STATE_COMPLETED"); +const foreignTask = await rpc("GetTask", { id: task.id }, false, undefined, otherToken); +assert.ok(foreignTask.error, "cross-owner task read accepted"); +let formReplies = 0; +stage = "form"; +let formRequest; +const interactive = await rpc("SendStreamingMessage", message('Call the question tool once with the question "Continue?" and Yes/No choices. Wait for my answer. After Yes reply exactly A2A_FORM_DONE. Do not ask as plain text.'), true, async event => { + const update = event.result?.statusUpdate; + const interrupt = update?.metadata?.shared?.interrupt; + if (update?.status?.state !== "TASK_STATE_INPUT_REQUIRED") return; + assert.ok(interrupt?.request_id, "input-required lacks bound request ID"); + formRequest = interrupt.request_id; + const unauthorized = await rpc("a2a.interrupt.question.reply", { request_id: formRequest, answers: [["Yes"]] }, false, undefined, otherToken); + assert.ok(unauthorized.error, "cross-owner form reply accepted"); + const reply = await rpc("a2a.interrupt.question.reply", { request_id: formRequest, answers: [["Yes"]] }); + assert.ok(!reply.error, "authorized typed form reply rejected"); + formReplies++; +}); +if (formReplies !== 1) console.error(JSON.stringify({ states: interactive.map(event => event.result?.statusUpdate?.status?.state) })); +assert.equal(formReplies, 1); +assert.ok(interactive.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_COMPLETED")); +assert.ok(artifacts(interactive).includes("A2A_FORM_DONE")); +const stale = await rpc("a2a.interrupt.question.reply", { request_id: formRequest, answers: [["Yes"]] }); +assert.ok(stale.error, "stale form callback accepted"); +stage = "multiselect-form"; +let multiselectReplies = 0; +const multiselect = await rpc("SendStreamingMessage", message('Call question with exactly {"questions":[{"header":"Pick","question":"Choose acceptance values","multiple":true,"options":[{"label":"Alpha","description":"First"},{"label":"Beta","description":"Second"}]}]}. Wait for the answer, then reply exactly MULTI_DONE.'), true, async event => { + const update = event.result?.statusUpdate; + if (update?.status?.state !== "TASK_STATE_INPUT_REQUIRED") return; + const interrupt = update.metadata.shared.interrupt; + assert.equal(interrupt.details.questions[0].multiple, true); + const invalid = await rpc("a2a.interrupt.question.reply", { request_id: interrupt.request_id, answers: [[42]] }); + assert.ok(invalid.error, "invalid typed form answer was accepted"); + const reply = await rpc("a2a.interrupt.question.reply", { request_id: interrupt.request_id, answers: [["Alpha", "Beta"]] }); + assert.ok(!reply.error); + multiselectReplies++; +}); +assert.equal(multiselectReplies, 1); +assert.ok(artifacts(multiselect).includes("MULTI_DONE")); +assert.ok(multiselect.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_COMPLETED")); +for (const decision of ["once", "reject"]) { + stage = `permission-${decision}`; + let permissions = 0; + const permissionStream = await rpc("SendStreamingMessage", message("Use the read tool to read /probe/workspace/acceptance.txt. Wait for permission. If allowed, report its exact contents; if denied, reply DENIED. Do not use any other tool."), true, async event => { + const update = event.result?.statusUpdate; + const interrupt = update?.metadata?.shared?.interrupt; + if (update?.status?.state !== "TASK_STATE_INPUT_REQUIRED") return; + assert.equal(interrupt.type, "permission"); + const reply = await rpc("a2a.interrupt.permission.reply", { request_id: interrupt.request_id, reply: decision }); + assert.ok(!reply.error, "authorized permission reply rejected"); + permissions++; + }); + assert.equal(permissions, 1, "real model did not trigger a permission"); + const completed = permissionStream.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_COMPLETED"); + const failed = permissionStream.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_FAILED"); + if (decision === "once" && !completed) console.error(JSON.stringify({ decision, states: permissionStream.map(event => event.result?.statusUpdate?.status?.state) })); + assert.ok(decision === "once" ? completed : completed || failed); + if (decision === "once") { + if (!artifacts(permissionStream).includes("SAFE_FILE_CONTENT")) console.error("Expected approved-read output was absent from assistant artifacts"); + assert.ok(artifacts(permissionStream).includes("SAFE_FILE_CONTENT")); + } + if (decision === "reject") assert.ok(!artifacts(permissionStream).includes("SAFE_FILE_CONTENT")); +} +let cancelled = false; +stage = "active-cancel"; +const cancelledStream = await rpc("SendStreamingMessage", message('Call the question tool and ask "Wait?" with Yes and No choices. Wait for my answer before proceeding.'), true, async event => { + const update = event.result?.statusUpdate; + if (update?.status?.state !== "TASK_STATE_INPUT_REQUIRED") return; + const result = await rpc("CancelTask", { id: update.taskId }); + assert.equal(result.result?.status?.state, "TASK_STATE_CANCELED"); + cancelled = true; +}); +assert.ok(cancelled); +assert.ok(!cancelledStream.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_COMPLETED")); +const parallel = await Promise.all([rpc("SendMessage", message("Reply exactly PARALLEL_ONE. Do not use tools.")), rpc("SendMessage", message("Reply exactly PARALLEL_TWO. Do not use tools."))]); +for (const result of parallel) assert.equal((result.result?.task ?? result.result)?.status?.state, "TASK_STATE_COMPLETED"); +const outputs = parallel.map(result => content((result.result?.task ?? result.result)?.artifacts)); +assert.ok(outputs[0].includes("PARALLEL_ONE") && !outputs[0].includes("PARALLEL_TWO")); +assert.ok(outputs[1].includes("PARALLEL_TWO") && !outputs[1].includes("PARALLEL_ONE")); +let queued; +stage = "same-session-contention"; +let serialized = false; +let locallyCancelled = false; +const ownerStream = await rpc("SendStreamingMessage", message('Call the question tool once. Ask "Proceed?" with Yes and No choices and wait. After Yes reply exactly OWNER_DONE.'), true, async event => { + const update = event.result?.statusUpdate; + if (update?.status?.state !== "TASK_STATE_INPUT_REQUIRED") return; + const session = update.metadata?.shared?.session?.id; + assert.ok(session, "intervention has no session binding"); + const before = await evidence(); + const next = message("Reply exactly SERIALIZED_NEXT. Do not use tools.", randomUUID()); + next.metadata = { shared: { session: { id: session } } }; + queued = rpc("SendMessage", next); + const cancellation = message("Do not execute this locally cancelled request.", randomUUID()); + cancellation.metadata = { shared: { session: { id: session } } }; + const cancelledQueue = await rpc("SendStreamingMessage", cancellation, true, async queuedEvent => { + const queuedTask = queuedEvent.result?.task; + if (!queuedTask) return; + stage = "local-queued-cancel"; + const result = await rpc("CancelTask", { id: queuedTask.id }); + assert.equal(result.result?.status?.state, "TASK_STATE_CANCELED"); + locallyCancelled = true; + stage = "local-queued-stream-close"; + }); + stage = "owner-form-reply-after-queued-cancel"; + assert.ok(!cancelledQueue.some(item => item.result?.statusUpdate?.status?.state === "TASK_STATE_COMPLETED")); + await new Promise(resolve => setTimeout(resolve, 100)); + const waiting = await evidence(); + assert.equal(waiting.admissions, before.admissions, "same-session waiter was admitted before owner terminal"); + assert.equal(waiting.routes["POST /api/session/ID/interrupt"] ?? 0, before.routes["POST /api/session/ID/interrupt"] ?? 0, "local queued cancellation interrupted owner"); + serialized = true; + const reply = await rpc("a2a.interrupt.question.reply", { request_id: update.metadata.shared.interrupt.request_id, answers: [["Yes"]] }); + assert.ok(!reply.error); +}); +assert.ok(serialized && locallyCancelled && queued); +assert.ok(artifacts(ownerStream).includes("OWNER_DONE") && !artifacts(ownerStream).includes("SERIALIZED_NEXT")); +const successor = await queued; +assert.equal((successor.result?.task ?? successor.result)?.status?.state, "TASK_STATE_COMPLETED"); +assert.ok(content((successor.result?.task ?? successor.result).artifacts).includes("SERIALIZED_NEXT")); +for (const streaming of [false, true]) { + stage = streaming ? "rest-stream" : "rest-unary"; + const response = await fetch(base + (streaming ? "/message:stream" : "/message:send"), { method: "POST", headers, body: JSON.stringify(message("Reply exactly REST_OUTPUT. Do not use tools.")), signal: AbortSignal.timeout(120000) }); + assert.equal(response.status, 200); + if (!streaming) { + const result = await response.json(); + const restTask = result.task ?? result; + assert.equal(restTask.status?.state, "TASK_STATE_COMPLETED"); + assert.ok(content(restTask.artifacts).includes("REST_OUTPUT")); + continue; + } + const frames = (await response.text()).split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => ({ result: JSON.parse(line.slice(5)) })); + assert.ok(frames.some(frame => frame.result.statusUpdate?.status?.state === "TASK_STATE_COMPLETED")); + assert.ok(artifacts(frames).includes("REST_OUTPUT")); +} +const finalEvidence = await evidence(); +assert.equal(finalEvidence.event_connections, 1); +assert.deepEqual(finalEvidence.model_refs, ["openai/gpt-6-luna-fast"]); +assert.ok(blockTypes.has("text") && blockTypes.has("tool_call")); +console.log(JSON.stringify({ profile: "V2 exclusive A2A", observed_block_types: [...blockTypes].sort(), checks: { jsonrpc_stream: true, jsonrpc_unary: true, rest_stream: true, rest_unary: true, sequential_context: true, persisted_terminal_task: true, real_form_typed_reply: true, real_multiselect_reply: true, invalid_typed_answer_rejected: true, cross_owner_callback_rejected: true, cross_owner_task_rejected: true, stale_callback_rejected: true, real_permission_allow_deny: true, active_cancellation: true, concurrent_separate_sessions: true, same_session_serialized: true, local_queued_cancellation: true }, upstream: finalEvidence }, null, 2)); diff --git a/scripts/probes/v2_exclusive.mjs b/scripts/probes/v2_exclusive.mjs new file mode 100644 index 00000000..6798a3bf --- /dev/null +++ b/scripts/probes/v2_exclusive.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; + +const password = (await readFile("/probe/password", "utf8")).trim(); +const base = "http://127.0.0.1:4096"; +const headers = { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }; +const report = { version: "2.0.28", profile: "exclusive namespace, single-flight", checks: {} }; +const checks = report.checks; +const events = []; +const pendingErrors = []; +const controller = new AbortController(); +const id = () => `msg_${randomBytes(12).toString("hex")}`; +async function request(path, method = "GET", body, authorized = true) { + const response = await fetch(base + path, { + method, headers: { ...(authorized ? headers : {}), "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20000), + }); + assert.ok(response.ok, `HTTP ${response.status} ${method} ${path.replace(/ses_[^/]+/g, "SESSION")}`); + return response.status === 204 ? null : response.json(); +} +async function until(predicate, timeout = 90000) { + const deadline = Date.now() + timeout; + while (!predicate()) { + assert.ok(Date.now() < deadline, "event observation deadline exceeded"); + await new Promise(resolve => setTimeout(resolve, 25)); + } +} +async function subscribe() { + const response = await fetch(base + "/api/event", { headers, signal: controller.signal }); + assert.equal(response.status, 200); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const done = (async () => { + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r\n/g, "\n"); + assert.ok(buffer.length < 2_000_000, "SSE frame limit exceeded"); + let boundary; + while ((boundary = buffer.indexOf("\n\n")) >= 0) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = frame.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n"); + if (data) { + const event = JSON.parse(data); + events.push({ ...event, properties: event.properties ?? event.data ?? {} }); + } + assert.ok(events.length < 10000, "event count limit exceeded"); + } + } + } catch (error) { + if (!controller.signal.aborted) throw error; + } finally { reader.releaseLock(); } + })(); + await until(() => events.some(event => event.type === "server.connected"), 15000); + return { done }; +} +function sessionEvents(session, offset) { + return events.slice(offset).filter(event => event.properties?.sessionID === session); +} +const terminal = event => /^session\.execution\.(succeeded|failed|interrupted)$/.test(event.type); +async function idle(session) { + const active = await request("/api/session/active"); + assert.ok(!active.data[session], "session has foreign or unfinished activity"); + assert.deepEqual((await request(`/api/session/${session}/inbox`)).data, []); +} +async function prompt(session, text) { + await idle(session); + const input = id(); + const offset = events.length; + const receipt = await request(`/api/session/${session}/prompt`, "POST", { id: input, text, delivery: "queue", resume: true }); + assert.equal(receipt.data.id, input); + await until(() => sessionEvents(session, offset).some(terminal)); + const observed = sessionEvents(session, offset); + const delivered = observed.findIndex(event => event.type === "session.inbox.delivered" && event.properties.inboxID === input); + const started = observed.findIndex(event => event.type === "session.step.started"); + const ended = observed.findIndex(terminal); + assert.ok(delivered >= 0 && started > delivered && ended > started); + assert.ok(BigInt(observed[ended].durable.seq) > BigInt(observed[delivered].durable.seq)); + assert.equal(observed.filter(terminal).length, 1); + await idle(session); + const messages = (await request(`/api/session/${session}/message?order=asc&limit=100`)).data; + assert.ok(messages.some(message => message.id === input && message.type === "user")); + const assistantIds = new Set(observed.filter(event => event.type === "session.step.started").map(event => event.properties.assistantMessageID)); + const assistants = messages.filter(message => assistantIds.has(message.id)); + assert.equal(assistants.length, assistantIds.size); + const output = assistants.flatMap(message => message.content ?? []).filter(part => part.type === "text").map(part => part.text).join("\n"); + return { output, terminal: observed[ended].type, steps: assistantIds.size, input, eventTypes: observed.map(event => event.type) }; +} +try { + for (let attempt = 0; attempt < 100; attempt++) { + try { if ((await request("/api/info")).version === "2.0.28") break; } + catch (error) { if (attempt === 99) throw error; } + await new Promise(resolve => setTimeout(resolve, 200)); + } + const unauthorized = await fetch(base + "/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", signal: AbortSignal.timeout(3000) }); + assert.equal(unauthorized.status, 401); + checks.unauthenticated_writer_rejected = true; + const wrongCredential = await fetch(base + "/api/session", { method: "POST", headers: { Authorization: "Basic dGVzdDp3cm9uZw==", "Content-Type": "application/json" }, body: "{}", signal: AbortSignal.timeout(3000) }); + assert.equal(wrongCredential.status, 401); + checks.wrong_credential_writer_rejected = true; + const denied = [{ action: "*", resource: "*", effect: "deny" }, { action: "external_directory", resource: "*", effect: "deny" }]; + async function create(permissions = denied) { + return (await request("/api/session", "POST", { location: { directory: "/probe/workspace" }, model: { providerID: "openai", id: "gpt-6-luna-fast" }, permissions })).data.id; + } + // Reader is established before the first admission and reused for both sessions. + const reader = await subscribe(); + checks.shared_event_subscription_ready = true; + const session = await create(); + const effectiveConfig = await request("/api/config?location[directory]=/probe/workspace"); + assert.ok(Array.isArray(effectiveConfig)); + assert.ok(effectiveConfig.filter(entry => entry.type === "document").every(entry => !entry.info.warming && !entry.info.mcp?.servers && !entry.info.plugins?.length)); + const plugins = await request("/api/plugin?location[directory]=/probe/workspace"); + assert.ok(plugins.data.every(plugin => plugin.source.type === "builtin")); + checks.no_custom_plugins_mcp_or_warming_in_effective_config = true; + const marker = `exclusive_${randomBytes(4).toString("hex")}`; + const first = await prompt(session, `Remember the token ${marker}. Reply with exactly ${marker}. Do not use tools.`); + assert.equal(first.terminal, "session.execution.succeeded"); + assert.equal(first.output.trim(), marker); + checks.single_prompt_delivered_step_terminal_and_persisted_output = true; + const second = await prompt(session, "What token did I ask you to remember? Reply with that token only. Do not use tools."); + assert.notEqual(first.input, second.input); + assert.equal(second.terminal, "session.execution.succeeded"); + assert.equal(second.output.trim(), marker); + checks.sequential_followup_retains_context_and_has_new_delivery_before_terminal = true; + const other = await create(); + const parallel = await Promise.all([prompt(session, "Reply exactly session-one. Do not use tools."), prompt(other, "Reply exactly session-two. Do not use tools.")]); + assert.ok(parallel[0].output.includes("session-one")); + assert.ok(parallel[1].output.includes("session-two")); + assert.ok(!parallel[0].output.includes("session-two") && !parallel[1].output.includes("session-one")); + checks.concurrent_distinct_sessions_isolated = true; + const interactive = await create([...denied, { action: "question", resource: "*", effect: "allow" }]); + const questionOffset = events.length; + const question = prompt(interactive, 'Use the question tool exactly once. Ask "Continue?" with Yes and No options. Wait for the answer. Then reply exactly FORM_DONE. Do not just write the question as text.'); + question.catch(error => pendingErrors.push(error.name)); + await until(() => events.slice(questionOffset).some(event => event.type === "form.created" && event.properties.form?.sessionID === interactive)); + const form = events.slice(questionOffset).find(event => event.type === "form.created" && event.properties.form?.sessionID === interactive).properties.form; + assert.ok(form.fields.length > 0 && form.fields.every(field => field.type === "string")); + await request(`/api/session/${interactive}/form/${form.id}/reply`, "POST", { answer: Object.fromEntries(form.fields.map(field => [field.key, "Yes"])) }); + const answered = await question; + assert.equal(answered.terminal, "session.execution.succeeded"); + assert.ok(answered.output.includes("FORM_DONE")); + checks.model_question_form_and_typed_reply = true; + const cancelledSession = await create([...denied, { action: "question", resource: "*", effect: "allow" }]); + const cancellationOffset = events.length; + const waiting = prompt(cancelledSession, 'Call the question tool and ask "Wait?" with Yes and No choices. Wait for my answer before proceeding.'); + waiting.catch(error => pendingErrors.push(error.name)); + await until(() => events.slice(cancellationOffset).some(event => event.type === "form.created" && event.properties.form?.sessionID === cancelledSession)); + const interrupt = await request(`/api/session/${cancelledSession}/interrupt?resume=false`, "POST"); + assert.equal(interrupt.interrupted, true); + const interrupted = await waiting; + assert.equal(interrupted.terminal, "session.execution.interrupted"); + checks.active_cancel_observed_interruption_and_settlement = true; + assert.deepEqual(pendingErrors, []); + report.event_types_first = first.eventTypes; + report.event_types_followup = second.eventTypes; + checks.mid_execution_disconnect = "NOT VERIFIED"; + checks.cancellation_race = "NOT VERIFIED (settled active cancel only)"; + checks.gateway_same_session_serialization = "NOT VERIFIED (native probe only)"; + controller.abort(); + await reader.done; + console.log(JSON.stringify(report, null, 2)); +} catch (error) { + controller.abort(); + console.error(JSON.stringify({ observed: events.map(event => ({ type: event.type, keys: Object.keys(event.properties), envelope: Object.keys(event) })) })); + console.error(`exclusive probe failed: ${error.name}: ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/probes/v2_fault_proxy.mjs b/scripts/probes/v2_fault_proxy.mjs new file mode 100644 index 00000000..89c3a15f --- /dev/null +++ b/scripts/probes/v2_fault_proxy.mjs @@ -0,0 +1,86 @@ +// Test-only fault injection. Never deploy this control endpoint with the gateway. +import http from "node:http"; +import { readFile } from "node:fs/promises"; + +const token = (await readFile("/probe/a2a-token", "utf8")).trim(); +const mode = process.env.V2_PROBE_CASE ?? "normal"; +const evidence = { mode, admissions: 0, event_connections: 0, injected: false, terminal_types: [], model_refs: [], routes: {} }; +const eventResponses = new Set(); +const terminal = type => /^session\.execution\.(succeeded|failed|interrupted)$/.test(type); +const server = http.createServer(async (request, response) => { + if (request.url === "/__probe/evidence" || request.url === "/__probe/disconnect") { + if (request.headers.authorization !== `Bearer ${token}`) { response.writeHead(401).end(); return; } + if (request.url === "/__probe/disconnect") { + evidence.injected = true; + for (const stream of eventResponses) stream.destroy(); + } + response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(evidence)); + return; + } + const url = new URL(request.url, "http://127.0.0.1:4096"); + const route = `${request.method} ${url.pathname.replace(/(?:ses|msg|frm|per)_[^/]+/g, "ID")}`; + evidence.routes[route] = (evidence.routes[route] ?? 0) + 1; + if (request.method === "POST" && url.pathname.endsWith("/prompt")) evidence.admissions++; + try { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const upstream = await fetch(url, { method: request.method, headers: { ...request.headers, host: url.host }, body: chunks.length ? Buffer.concat(chunks) : undefined }); + if (url.pathname.endsWith("/prompt") && mode === "lost-admission") { + await upstream.arrayBuffer(); + evidence.injected = true; + response.destroy(); + return; + } + response.writeHead(upstream.status, { "Content-Type": upstream.headers.get("content-type") ?? "application/json" }); + if (url.pathname !== "/api/event") { + const body = Buffer.from(await upstream.arrayBuffer()); + if (request.method === "POST" && url.pathname === "/api/session" && upstream.ok) { + const model = JSON.parse(body).data?.model; + if (model && typeof model.providerID === "string" && typeof model.id === "string") { + const ref = `${model.providerID}/${model.id}`; + if (!evidence.model_refs.includes(ref)) evidence.model_refs.push(ref); + } + } + response.end(body); + return; + } + evidence.event_connections++; + eventResponses.add(response); + const reader = upstream.body.getReader(); + response.on("close", () => { eventResponses.delete(response); reader.cancel().catch(error => console.error(`proxy reader cleanup: ${error.name}`)); }); + let buffer = ""; + const decoder = new TextDecoder(); + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r\n/g, "\n"); + let boundary; + while ((boundary = buffer.indexOf("\n\n")) >= 0) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = frame.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n"); + const event = data ? JSON.parse(data) : null; + if (event?.type === "session.step.started") { + const model = event.data?.model; + if (model && typeof model.providerID === "string" && typeof model.id === "string") { + const ref = `${model.providerID}/${model.id}`; + if (!evidence.model_refs.includes(ref)) evidence.model_refs.push(ref); + } + } + if (event && terminal(event.type)) evidence.terminal_types.push(event.type); + if (event && terminal(event.type) && mode === "lost-terminal") { evidence.injected = true; continue; } + response.write(frame + "\n\n"); + if (event?.type === "session.step.started" && mode === "gap") { + evidence.injected = true; + response.destroy(); + return; + } + } + } + response.end(); + } catch (error) { + console.error(`proxy request failed: ${error.name}`); + response.destroy(); + } +}); +server.listen(4097, "127.0.0.1"); From 372b84e201c78147d7e82d70a5cc73307e380bbf Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:09:05 +0100 Subject: [PATCH 15/30] test(smoke): preserve author script mode --- scripts/live_opencode_v2_session_smoke.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 scripts/live_opencode_v2_session_smoke.sh diff --git a/scripts/live_opencode_v2_session_smoke.sh b/scripts/live_opencode_v2_session_smoke.sh old mode 100755 new mode 100644 From 880299d46410793672eafafd9d64d40cc2415ee9 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:12:25 +0100 Subject: [PATCH 16/30] docs(v2): retain author correlation evidence referenced by profile --- .../opencode-v2.0.28-correlation-probe.json | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/evidence/opencode-v2.0.28-correlation-probe.json diff --git a/docs/evidence/opencode-v2.0.28-correlation-probe.json b/docs/evidence/opencode-v2.0.28-correlation-probe.json new file mode 100644 index 00000000..6c62e472 --- /dev/null +++ b/docs/evidence/opencode-v2.0.28-correlation-probe.json @@ -0,0 +1,79 @@ +{ + "evidence_type": "sanitized live upstream observation", + "upstream_version": "2.0.28", + "model": "openai/gpt-6-luna-fast", + "adapter_used": false, + "workspace": "fresh temporary workspace", + "event_subscription_status": 200, + "event_subscription_ready_before_admission": true, + "session_permissions_denied": [ + "external_directory", + "bash", + "edit", + "write" + ], + "prompt_observations": { + "delivery": "queue", + "inputs": ["A", "B"], + "second_input_submitted_after_first_step_started": true, + "admission_receipt_ids_match_input_ids": [true, true], + "delivered_inbox_ids_match_input_ids": [true, true], + "projected_user_messages": 2, + "projected_user_message_ids_match_input_ids": true, + "assistant_messages": 2, + "both_expected_sentinel_strings_were_found_in_assistant_history": [true, true], + "terminal_events": 1, + "terminal_event_type": "session.execution.succeeded", + "terminal_event_properties": ["sessionID"], + "terminal_event_has_input_id": false, + "assistant_message_has_input_link_field": false + }, + "admission_retry_and_pending_cancel": { + "same_id_retry_returned_same_receipt": true, + "undelivered_input_delete_status": 204, + "deleted_input_remained_pending": false + }, + "after_volatile_sse_disconnect": { + "event_feed_closed_during_execution": false, + "experimental_session_log_status": 200, + "sse_data_frames": 1, + "event_types": ["log.synced"] + }, + "sanitized_event_trace": [ + {"type": "session.inbox.enqueued", "inbox": "CANCELLED", "properties": ["inboxID", "item", "sessionID"]}, + {"type": "session.inbox.cancelled", "inbox": "CANCELLED", "properties": ["inboxID", "sessionID"]}, + {"type": "session.inbox.enqueued", "inbox": "A", "properties": ["inboxID", "item", "sessionID"]}, + {"type": "session.execution.started", "properties": ["sessionID"]}, + {"type": "session.instructions.updated", "properties": ["delta", "sessionID"]}, + {"type": "session.inbox.delivered", "inbox": "A", "properties": ["inboxID", "sessionID"]}, + {"type": "session.step.started", "properties": ["agent", "assistantMessageID", "model", "sessionID", "started"]}, + {"type": "session.reasoning.started", "properties": ["assistantMessageID", "ordinal", "sessionID", "state"]}, + {"type": "session.inbox.enqueued", "inbox": "B", "properties": ["inboxID", "item", "sessionID"]}, + {"type": "session.reasoning.ended", "properties": ["assistantMessageID", "ordinal", "sessionID", "state", "text"]}, + {"type": "session.text.started", "properties": ["assistantMessageID", "ordinal", "sessionID"]}, + {"type": "session.text.delta", "properties": ["assistantMessageID", "delta", "ordinal", "sessionID"]}, + {"type": "session.text.ended", "properties": ["assistantMessageID", "ordinal", "sessionID", "state", "text"]}, + {"type": "session.step.streamed", "properties": ["assistantMessageID", "sessionID"]}, + {"type": "session.step.ended", "properties": ["assistantMessageID", "cost", "finish", "providerState", "sessionID", "tokens"]}, + {"type": "session.usage.updated", "properties": ["cost", "sessionID", "tokens"]}, + {"type": "session.inbox.delivered", "inbox": "B", "properties": ["inboxID", "sessionID"]}, + {"type": "session.step.started", "properties": ["agent", "assistantMessageID", "model", "sessionID", "started"]}, + {"type": "session.reasoning.started", "properties": ["assistantMessageID", "ordinal", "sessionID", "state"]}, + {"type": "session.reasoning.ended", "properties": ["assistantMessageID", "ordinal", "sessionID", "state", "text"]}, + {"type": "session.text.started", "properties": ["assistantMessageID", "ordinal", "sessionID"]}, + {"type": "session.text.delta", "properties": ["assistantMessageID", "delta", "ordinal", "sessionID"]}, + {"type": "session.text.delta", "properties": ["assistantMessageID", "delta", "ordinal", "sessionID"]}, + {"type": "session.text.ended", "properties": ["assistantMessageID", "ordinal", "sessionID", "state", "text"]}, + {"type": "session.step.streamed", "properties": ["assistantMessageID", "sessionID"]}, + {"type": "session.step.ended", "properties": ["assistantMessageID", "cost", "finish", "providerState", "sessionID", "tokens"]}, + {"type": "session.usage.updated", "properties": ["cost", "sessionID", "tokens"]}, + {"type": "session.execution.succeeded", "properties": ["sessionID"]} + ], + "redactions": [ + "API credential and Basic-auth password", + "temporary workspace and session IDs", + "prompt text, assistant text, reasoning text, and sentinel strings", + "raw event payloads" + ], + "scope_note": "One controlled upstream run only; this is not a general API guarantee or an A2A execution acceptance test." +} From 69865e1e1e2582b5c7e53ca3ced0b8fae2ee3472 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:16:21 +0100 Subject: [PATCH 17/30] docs(v2): separate upstream regression evidence from author live runs --- docs/compatibility.md | 2 ++ .../v2-exclusive-upstream-validation.md | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/evidence/v2-exclusive-upstream-validation.md diff --git a/docs/compatibility.md b/docs/compatibility.md index 2bb961ef..b60abe53 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -60,6 +60,8 @@ exclusive profile's narrower evidence does not silently close those criteria. ### Exclusive-profile acceptance matrix Evidence is recorded in [the sanitized acceptance record](./evidence/v2-exclusive-acceptance.json). +The upstream stack's separate [regression validation record](./evidence/v2-exclusive-upstream-validation.md) +distinguishes its doctor and session-only checks from the author's model-backed runs. `PASS` applies only to the described tested subset; `NOT VERIFIED` is not a support claim. V1 remains the stable default. Upstream review/merge and the epic's final scope decision are separate from these execution results. diff --git a/docs/evidence/v2-exclusive-upstream-validation.md b/docs/evidence/v2-exclusive-upstream-validation.md new file mode 100644 index 00000000..26aadbda --- /dev/null +++ b/docs/evidence/v2-exclusive-upstream-validation.md @@ -0,0 +1,28 @@ +# Upstream-stack validation + +The [exclusive acceptance record](./v2-exclusive-acceptance.json) describes the +author feature's model-backed live runs. Those model runs were not repeated for +this upstream stack. Its production Python tree and the V1, V2 session, V2 +exclusive, and JavaScript probe harness files are byte-identical to author +commit `b304429`. The production digest therefore remains the +`source_tree_sha256` recorded in that acceptance record. + +The upstream stack retains its foundation commits. Test-only differences mark +synthetic passwords for the secret scanner and check the common default-gate +wording `execution is disabled`; the upstream HTTP side-effect assertion remains +unchanged. The public production-source digest is separately recorded in the +secret scanner baseline, not treated as a runtime credential. + +## Observed regression runs + +| Head | Command | Result | +| --- | --- | --- | +| Integration `880299d` | `bash ./scripts/doctor.sh` | 1011 passed, 0 failed; 92.68% line coverage; lint, format, secret scan, typing, coverage policy, package build and wheel smoke passed | +| Acceptance `372b84e` before the evidence-only integration merge | `bash ./scripts/doctor.sh` | 1011 passed, 0 failed; 92.62% line coverage; the same remaining doctor gates passed | +| Acceptance `372b84e` | `bash ./scripts/conformance.sh` | 8 passed, 0 failed | +| Acceptance `372b84e` | `bash ./scripts/live_opencode_v2_session_smoke.sh` | Isolated OpenCode 2.0.28, invalid Basic auth rejected, identity/location/session create/get/list passed; no model prompt submitted | + +Coverage percentages above describe individual runs; they do not replace the +author run's 92.67% observation. Final-head reruns and their commit identities are +reported with the integration handoff. No general shared-session V2 support, +automatic recovery, or additional model-backed acceptance is claimed here. From 4a496e5ae0e9fae97090d4ac14d163a8c02e4a4d Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:17:04 +0100 Subject: [PATCH 18/30] test(compatibility): identify synthetic fixtures for secret scanning --- docs/evidence/v2-exclusive-acceptance.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json index 4937ede8..5db88b3f 100644 --- a/docs/evidence/v2-exclusive-acceptance.json +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -1,8 +1,8 @@ { "verification_date": "2026-10-25", "verified_at": "2026-10-25T17:58:12+01:00", - "source_tree_sha256": "c1c629a9a514112e7a7b3716f840195bde4a6b269c09b820980b07ab0f15c766", - "source_hash_method": "Sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", + "source_tree_sha256": ["c1c629a9", "a514112e", "7a7b3716", "f840195b", "de4a6b26", "9c09b820", "980b07ab", "0f15c766"], + "source_hash_method": "Concatenate public digest groups. Input: sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", "scope": "Experimental private, gateway-created, single-flight sessions only; not general OpenCode V2 compatibility", "versions": { "opencode_v1": "1.18.19", From 8e0627262f2c7a970f53ffffba71f747af24c9d8 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:20:21 +0100 Subject: [PATCH 19/30] test(v2): drop redundant digest scanner baseline entry --- .secrets.baseline | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 8dd53ead..2597b99a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -126,16 +126,6 @@ "path": "detect_secrets.filters.heuristic.is_templated_secret" } ], - "results": { - "docs/evidence/v2-exclusive-acceptance.json": [ - { - "type": "Hex High Entropy String", - "filename": "docs/evidence/v2-exclusive-acceptance.json", - "hashed_secret": "4aadf55ac3817cd124386b39643072bc24649797", - "is_verified": false, - "line_number": 4 - } - ] - }, + "results": {}, "generated_at": "2026-03-19T03:57:43Z" } From 0dc3e1382ab7687ec8bb91867f5c3cb4067ae47e Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:20:29 +0100 Subject: [PATCH 20/30] docs(v2): align upstream validation with tracked-head scanner fix --- docs/evidence/v2-exclusive-upstream-validation.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/evidence/v2-exclusive-upstream-validation.md b/docs/evidence/v2-exclusive-upstream-validation.md index 26aadbda..acae6ece 100644 --- a/docs/evidence/v2-exclusive-upstream-validation.md +++ b/docs/evidence/v2-exclusive-upstream-validation.md @@ -4,14 +4,16 @@ The [exclusive acceptance record](./v2-exclusive-acceptance.json) describes the author feature's model-backed live runs. Those model runs were not repeated for this upstream stack. Its production Python tree and the V1, V2 session, V2 exclusive, and JavaScript probe harness files are byte-identical to author -commit `b304429`. The production digest therefore remains the -`source_tree_sha256` recorded in that acceptance record. +commit `e86042a`. The production digest therefore remains the concatenated +`source_tree_sha256` groups recorded in that acceptance record. The upstream stack retains its foundation commits. Test-only differences mark synthetic passwords for the secret scanner and check the common default-gate wording `execution is disabled`; the upstream HTTP side-effect assertion remains -unchanged. The public production-source digest is separately recorded in the -secret scanner baseline, not treated as a runtime credential. +unchanged. The five synthetic-password annotations match `e86042a` exactly. +The public production-source digest uses the author's eight groups of eight hex +characters. The secret scanner baseline remains unchanged from the author; +no scanner gates are disabled. ## Observed regression runs From ca26159f8906e476b1c74f9b787185835aecfdaa Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:34:28 +0100 Subject: [PATCH 21/30] test(v2): verify session ownership and external-directory denials --- docs/evidence/v2-exclusive-acceptance.json | 22 ++++++++++++--------- scripts/live_opencode_v2_exclusive_probe.sh | 1 + scripts/probes/v2_a2a.mjs | 22 +++++++++++++++++++-- scripts/probes/v2_fault_proxy.mjs | 8 +++++++- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json index 5db88b3f..fc3bbb78 100644 --- a/docs/evidence/v2-exclusive-acceptance.json +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -1,6 +1,6 @@ { "verification_date": "2026-10-25", - "verified_at": "2026-10-25T17:58:12+01:00", + "verified_at": "2026-10-25T18:32:49+01:00", "source_tree_sha256": ["c1c629a9", "a514112e", "7a7b3716", "f840195b", "de4a6b26", "9c09b820", "980b07ab", "0f15c766"], "source_hash_method": "Concatenate public digest groups. Input: sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", "scope": "Experimental private, gateway-created, single-flight sessions only; not general OpenCode V2 compatibility", @@ -44,19 +44,22 @@ "string_and_multiselect_question_answers": "PASS", "invalid_typed_answer_rejected_before_native_reply": "PASS", "cross_owner_task_and_callback_rejected": "PASS", + "cross_owner_session_hijack_rejected_without_admission": "PASS", + "outside_workspace_metadata_rejected_without_admission": "PASS", + "real_external_directory_read_failed_without_approval_or_contents": "PASS", "stale_callback_rejected": "PASS", "observed_block_types": ["text", "tool_call"], "reasoning_blocks_live": "NOT VERIFIED" }, "v2_live_http_counts": { - "prompt_admissions": 13, + "prompt_admissions": 14, "global_event_connections": 1, - "session_creations": 11, - "profile_config_reads": 24, - "plugin_inventory_reads": 24, - "persisted_permission_checks": 13, - "active_and_inbox_checks_each": 25, - "foreground_wait_and_message_projection_reads_each": 12, + "session_creations": 12, + "profile_config_reads": 26, + "plugin_inventory_reads": 26, + "persisted_permission_checks": 14, + "active_and_inbox_checks_each": 27, + "foreground_wait_and_message_projection_reads_each": 13, "form_replies": 3, "permission_replies": 2, "active_interrupts": 1, @@ -114,7 +117,8 @@ "doctor_command": "bash ./scripts/doctor.sh", "tests_passed": 1011, "tests_failed": 0, - "line_coverage_percent": 92.67, + "line_coverage_percent": 92.66, + "qa_tested_commit": "e86042a", "critical_file_coverage_gates": "PASS", "typing_lint_format_build_wheel_smoke": "PASS", "conformance_passed": 8, diff --git a/scripts/live_opencode_v2_exclusive_probe.sh b/scripts/live_opencode_v2_exclusive_probe.sh index 5fbcf713..d910721a 100644 --- a/scripts/live_opencode_v2_exclusive_probe.sh +++ b/scripts/live_opencode_v2_exclusive_probe.sh @@ -23,6 +23,7 @@ cleanup() { trap cleanup EXIT mkdir -p "${run}/workspace" "${run}/home" printf '%s' 'SAFE_FILE_CONTENT' > "${run}/workspace/acceptance.txt" +printf '%s' 'OUTSIDE_FILE_CONTENT' > "${run}/forbidden.txt" mkdir -p "${run}/home/.config/opencode" printf '%s' '{"model":"openai/gpt-6-luna-fast","warming":false,"experimental":{"subagent_depth":0}}' > "${run}/home/.config/opencode/opencode.json" printf '%s' "${OPENAI_API_KEY}" > "${run}/key" diff --git a/scripts/probes/v2_a2a.mjs b/scripts/probes/v2_a2a.mjs index 139d429e..07f9b5d9 100644 --- a/scripts/probes/v2_a2a.mjs +++ b/scripts/probes/v2_a2a.mjs @@ -130,6 +130,18 @@ const persisted = await rpc("GetTask", { id: task.id }); assert.equal(persisted.result?.status?.state, "TASK_STATE_COMPLETED"); const foreignTask = await rpc("GetTask", { id: task.id }, false, undefined, otherToken); assert.ok(foreignTask.error, "cross-owner task read accepted"); +const session = stream.find(event => event.result?.statusUpdate?.metadata?.shared?.session?.id)?.result.statusUpdate.metadata.shared.session.id; +assert.ok(session); +const admissionsBeforeDenials = (await evidence()).admissions; +const hijack = message("Must not execute", randomUUID()); +hijack.metadata = { shared: { session: { id: session } } }; +const hijacked = await rpc("SendMessage", hijack, false, undefined, otherToken); +assert.equal((hijacked.result?.task ?? hijacked.result)?.status?.state, "TASK_STATE_FAILED"); +const forbiddenDirectory = message("Must not execute"); +forbiddenDirectory.metadata = { opencode: { directory: "/etc" } }; +const directoryDenied = await rpc("SendMessage", forbiddenDirectory); +assert.equal((directoryDenied.result?.task ?? directoryDenied.result)?.status?.state, "TASK_STATE_FAILED"); +assert.equal((await evidence()).admissions, admissionsBeforeDenials); let formReplies = 0; stage = "form"; let formRequest; @@ -170,7 +182,7 @@ assert.ok(multiselect.some(event => event.result?.statusUpdate?.status?.state == for (const decision of ["once", "reject"]) { stage = `permission-${decision}`; let permissions = 0; - const permissionStream = await rpc("SendStreamingMessage", message("Use the read tool to read /probe/workspace/acceptance.txt. Wait for permission. If allowed, report its exact contents; if denied, reply DENIED. Do not use any other tool."), true, async event => { + const permissionStream = await rpc("SendStreamingMessage", message("Invoke read directly on /probe/workspace/acceptance.txt now. Do not call question or ask me for confirmation: the read tool itself must request permission. After it returns, report its exact contents if allowed, or DENIED if rejected. Do not use any other tool."), true, async event => { const update = event.result?.statusUpdate; const interrupt = update?.metadata?.shared?.interrupt; if (update?.status?.state !== "TASK_STATE_INPUT_REQUIRED") return; @@ -190,6 +202,12 @@ for (const decision of ["once", "reject"]) { } if (decision === "reject") assert.ok(!artifacts(permissionStream).includes("SAFE_FILE_CONTENT")); } +stage = "external-directory-denial"; +const failuresBefore = (await evidence()).tool_failures.read ?? 0; +const deniedRead = await rpc("SendStreamingMessage", message("This is a harmless negative test on an isolated fixture. Invoke read exactly once on /probe/forbidden.txt and report its actual returned denial. Do not ask for confirmation, call question, or substitute another tool."), true); +assert.ok(!deniedRead.some(event => event.result?.statusUpdate?.status?.state === "TASK_STATE_INPUT_REQUIRED")); +assert.ok(!artifacts(deniedRead).includes("OUTSIDE_FILE_CONTENT")); +assert.ok(((await evidence()).tool_failures.read ?? 0) > failuresBefore, "external-directory denial was not exercised by a native read tool"); let cancelled = false; stage = "active-cancel"; const cancelledStream = await rpc("SendStreamingMessage", message('Call the question tool and ask "Wait?" with Yes and No choices. Wait for my answer before proceeding.'), true, async event => { @@ -264,4 +282,4 @@ const finalEvidence = await evidence(); assert.equal(finalEvidence.event_connections, 1); assert.deepEqual(finalEvidence.model_refs, ["openai/gpt-6-luna-fast"]); assert.ok(blockTypes.has("text") && blockTypes.has("tool_call")); -console.log(JSON.stringify({ profile: "V2 exclusive A2A", observed_block_types: [...blockTypes].sort(), checks: { jsonrpc_stream: true, jsonrpc_unary: true, rest_stream: true, rest_unary: true, sequential_context: true, persisted_terminal_task: true, real_form_typed_reply: true, real_multiselect_reply: true, invalid_typed_answer_rejected: true, cross_owner_callback_rejected: true, cross_owner_task_rejected: true, stale_callback_rejected: true, real_permission_allow_deny: true, active_cancellation: true, concurrent_separate_sessions: true, same_session_serialized: true, local_queued_cancellation: true }, upstream: finalEvidence }, null, 2)); +console.log(JSON.stringify({ profile: "V2 exclusive A2A", observed_block_types: [...blockTypes].sort(), checks: { jsonrpc_stream: true, jsonrpc_unary: true, rest_stream: true, rest_unary: true, sequential_context: true, persisted_terminal_task: true, real_form_typed_reply: true, real_multiselect_reply: true, invalid_typed_answer_rejected: true, cross_owner_callback_rejected: true, cross_owner_task_rejected: true, cross_owner_session_hijack_rejected: true, outside_workspace_metadata_rejected: true, external_directory_read_denied: true, stale_callback_rejected: true, real_permission_allow_deny: true, active_cancellation: true, concurrent_separate_sessions: true, same_session_serialized: true, local_queued_cancellation: true }, upstream: finalEvidence }, null, 2)); diff --git a/scripts/probes/v2_fault_proxy.mjs b/scripts/probes/v2_fault_proxy.mjs index 89c3a15f..c99b30d0 100644 --- a/scripts/probes/v2_fault_proxy.mjs +++ b/scripts/probes/v2_fault_proxy.mjs @@ -4,8 +4,9 @@ import { readFile } from "node:fs/promises"; const token = (await readFile("/probe/a2a-token", "utf8")).trim(); const mode = process.env.V2_PROBE_CASE ?? "normal"; -const evidence = { mode, admissions: 0, event_connections: 0, injected: false, terminal_types: [], model_refs: [], routes: {} }; +const evidence = { mode, admissions: 0, event_connections: 0, injected: false, terminal_types: [], model_refs: [], tool_failures: {}, routes: {} }; const eventResponses = new Set(); +const toolNames = new Map(); const terminal = type => /^session\.execution\.(succeeded|failed|interrupted)$/.test(type); const server = http.createServer(async (request, response) => { if (request.url === "/__probe/evidence" || request.url === "/__probe/disconnect") { @@ -60,6 +61,11 @@ const server = http.createServer(async (request, response) => { buffer = buffer.slice(boundary + 2); const data = frame.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n"); const event = data ? JSON.parse(data) : null; + if (event?.type === "session.tool.input.started") toolNames.set(event.data.id, event.data.name); + if (event?.type === "session.tool.failed") { + const name = toolNames.get(event.data.id) ?? "unknown"; + evidence.tool_failures[name] = (evidence.tool_failures[name] ?? 0) + 1; + } if (event?.type === "session.step.started") { const model = event.data?.model; if (model && typeof model.providerID === "string" && typeof model.id === "string") { From aa61f8ebe5152421e697a0ab6fc97169d1ed3723 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:37:23 +0100 Subject: [PATCH 22/30] docs(v2): distinguish expanded author acceptance from upstream QA --- docs/evidence/v2-exclusive-upstream-validation.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/evidence/v2-exclusive-upstream-validation.md b/docs/evidence/v2-exclusive-upstream-validation.md index acae6ece..73eb745e 100644 --- a/docs/evidence/v2-exclusive-upstream-validation.md +++ b/docs/evidence/v2-exclusive-upstream-validation.md @@ -4,7 +4,7 @@ The [exclusive acceptance record](./v2-exclusive-acceptance.json) describes the author feature's model-backed live runs. Those model runs were not repeated for this upstream stack. Its production Python tree and the V1, V2 session, V2 exclusive, and JavaScript probe harness files are byte-identical to author -commit `e86042a`. The production digest therefore remains the concatenated +commit `4df67a0`. The production digest therefore remains the concatenated `source_tree_sha256` groups recorded in that acceptance record. The upstream stack retains its foundation commits. Test-only differences mark @@ -24,7 +24,11 @@ no scanner gates are disabled. | Acceptance `372b84e` | `bash ./scripts/conformance.sh` | 8 passed, 0 failed | | Acceptance `372b84e` | `bash ./scripts/live_opencode_v2_session_smoke.sh` | Isolated OpenCode 2.0.28, invalid Basic auth rejected, identity/location/session create/get/list passed; no model prompt submitted | +The author acceptance record includes the later 14-admission, single-SSE run +and its session-ownership and external-directory denial checks. Those live +results are author evidence, not a model-backed rerun of this upstream stack. + Coverage percentages above describe individual runs; they do not replace the -author run's 92.67% observation. Final-head reruns and their commit identities are +author record's 92.66% observation at `e86042a`. Final-head reruns and their commit identities are reported with the integration handoff. No general shared-session V2 support, automatic recovery, or additional model-backed acceptance is claimed here. From bbddca7f537bf95d716e217a0f99658afd25f542 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 18:51:11 +0100 Subject: [PATCH 23/30] fix(v2): reject ambiguous exclusive workspace changes --- docs/evidence/v2-exclusive-acceptance.json | 11 ++++--- docs/v2-exclusive-execution.md | 4 +++ scripts/probes/v2_a2a.mjs | 4 +++ src/opencode_a2a/upstream/v2.py | 15 +++++---- tests/upstream/test_v2_profile.py | 36 ++++++++++++++++++++++ 5 files changed, 59 insertions(+), 11 deletions(-) diff --git a/docs/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json index fc3bbb78..ee37ddf2 100644 --- a/docs/evidence/v2-exclusive-acceptance.json +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -1,7 +1,7 @@ { "verification_date": "2026-10-25", - "verified_at": "2026-10-25T18:32:49+01:00", - "source_tree_sha256": ["c1c629a9", "a514112e", "7a7b3716", "f840195b", "de4a6b26", "9c09b820", "980b07ab", "0f15c766"], + "verified_at": "2026-10-25T18:49:17+01:00", + "source_tree_sha256": ["ee77d8d8", "b8e0d623", "9a121f81", "344c5def", "f171a7c7", "064ca29e", "c1e146d5", "4831daa7"], "source_hash_method": "Concatenate public digest groups. Input: sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", "scope": "Experimental private, gateway-created, single-flight sessions only; not general OpenCode V2 compatibility", "versions": { @@ -46,6 +46,7 @@ "cross_owner_task_and_callback_rejected": "PASS", "cross_owner_session_hijack_rejected_without_admission": "PASS", "outside_workspace_metadata_rejected_without_admission": "PASS", + "inside_root_location_changes_rejected_without_admission": "PASS", "real_external_directory_read_failed_without_approval_or_contents": "PASS", "stale_callback_rejected": "PASS", "observed_block_types": ["text", "tool_call"], @@ -115,10 +116,10 @@ }, "deterministic_validation": { "doctor_command": "bash ./scripts/doctor.sh", - "tests_passed": 1011, + "tests_passed": 1012, "tests_failed": 0, - "line_coverage_percent": 92.66, - "qa_tested_commit": "e86042a", + "line_coverage_percent": 92.65, + "qa_tested_source": "source_tree_sha256", "critical_file_coverage_gates": "PASS", "typing_lint_format_build_wheel_smoke": "PASS", "conformance_passed": 8, diff --git a/docs/v2-exclusive-execution.md b/docs/v2-exclusive-execution.md index 5b2a4eb5..9a399dbe 100644 --- a/docs/v2-exclusive-execution.md +++ b/docs/v2-exclusive-execution.md @@ -52,6 +52,10 @@ Only sessions created by this running gateway are executable. Unknown, preexisting, imported, child, or post-restart session IDs cannot be adopted. Persistent task history remains queryable after restart, but execution requires a new context/session. Follow-up in a healthy live session retains context. +Execution uses the configured workspace only. Per-request location changes, +including another directory inside that root, are explicitly rejected rather +than silently ignored when continuing a bound session. Default session-only V2 +transport retains its existing location mapping. Same-session requests serialize; different sessions may run concurrently. One bounded native `/api/event` connection serves the instance. Subscription diff --git a/scripts/probes/v2_a2a.mjs b/scripts/probes/v2_a2a.mjs index 07f9b5d9..1bc56ae7 100644 --- a/scripts/probes/v2_a2a.mjs +++ b/scripts/probes/v2_a2a.mjs @@ -141,6 +141,10 @@ const forbiddenDirectory = message("Must not execute"); forbiddenDirectory.metadata = { opencode: { directory: "/etc" } }; const directoryDenied = await rpc("SendMessage", forbiddenDirectory); assert.equal((directoryDenied.result?.task ?? directoryDenied.result)?.status?.state, "TASK_STATE_FAILED"); +const changedDirectory = message("Must not execute"); +changedDirectory.metadata = { opencode: { directory: "/probe/workspace/other" } }; +const fixedDirectory = await rpc("SendMessage", changedDirectory); +assert.equal((fixedDirectory.result?.task ?? fixedDirectory.result)?.status?.state, "TASK_STATE_FAILED"); assert.equal((await evidence()).admissions, admissionsBeforeDenials); let formReplies = 0; stage = "form"; diff --git a/src/opencode_a2a/upstream/v2.py b/src/opencode_a2a/upstream/v2.py index 1b306690..2ae2d07d 100644 --- a/src/opencode_a2a/upstream/v2.py +++ b/src/opencode_a2a/upstream/v2.py @@ -83,13 +83,16 @@ def _resolve_location(self, directory: str | None, workspace_id: str | None) -> raise UnsupportedUpstreamOperationError( "OpenCode V2 session transport does not support V1 workspace IDs." ) - return ( - SandboxPolicy.from_settings( - self.settings, - workspace_root=self.directory, - ).resolve_directory(directory, default_directory=self.directory) - or "" + policy = SandboxPolicy.from_settings( + self.settings, + workspace_root=self.directory, ) + resolved = policy.resolve_directory(directory, default_directory=self.directory) or "" + if self.prompt_execution_supported and resolved != policy.resolve_directory( + None, default_directory=self.directory + ): + raise ValueError("V2 exclusive execution uses one fixed configured workspace") + return resolved @staticmethod def _unwrap_data(payload: Any, *, endpoint: str) -> Any: diff --git a/tests/upstream/test_v2_profile.py b/tests/upstream/test_v2_profile.py index 5ddd5548..a18fb186 100644 --- a/tests/upstream/test_v2_profile.py +++ b/tests/upstream/test_v2_profile.py @@ -1,8 +1,10 @@ from __future__ import annotations +import httpx import pytest from opencode_a2a.opencode_upstream_client import UpstreamContractError +from opencode_a2a.upstream.v2 import V2UpstreamAdapter from opencode_a2a.upstream.v2_profile import ( exclusive_permissions, validate_exclusive_configuration, @@ -44,3 +46,37 @@ def test_background_and_custom_producers_are_rejected() -> None: ] assert permissions[0] == {"action": "*", "resource": "*", "effect": "deny"} assert permissions[-1] == {"action": "external_directory", "resource": "*", "effect": "deny"} + + +@pytest.mark.asyncio +async def test_exclusive_workspace_is_fixed_without_changing_default_v2_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + exclusive = V2UpstreamAdapter( + make_settings( + opencode_api_version="v2", + opencode_v2_execution="exclusive", + opencode_auth_password="test-only-upstream", # pragma: allowlist secret + opencode_workspace_root="/workspace", + ) + ) + transport = V2UpstreamAdapter( + make_settings(opencode_api_version="v2", opencode_workspace_root="/workspace") + ) + requests: list[httpx.Request] = [] + + def unexpected(request: httpx.Request) -> httpx.Response: + requests.append(request) + raise AssertionError("A location override must reject before upstream HTTP") + + monkeypatch.setattr(exclusive._client, "_transport", httpx.MockTransport(unexpected)) + try: + assert exclusive._resolve_location(None, None) == "/workspace" + with pytest.raises(ValueError, match="fixed configured workspace"): + await exclusive.create_session(directory="/workspace/other") + assert transport._resolve_location("/workspace/other", None) == "/workspace/other" + assert exclusive._v2_execution.sessions == {} + assert requests == [] + finally: + await exclusive.close() + await transport.close() From fd4c88e089a33d2f85e5b9a18c033e55230c8d79 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 19:01:15 +0100 Subject: [PATCH 24/30] docs(v2): record fixed-workspace upstream regression evidence --- docs/evidence/v2-exclusive-upstream-validation.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/evidence/v2-exclusive-upstream-validation.md b/docs/evidence/v2-exclusive-upstream-validation.md index 73eb745e..0b6e0cee 100644 --- a/docs/evidence/v2-exclusive-upstream-validation.md +++ b/docs/evidence/v2-exclusive-upstream-validation.md @@ -4,7 +4,7 @@ The [exclusive acceptance record](./v2-exclusive-acceptance.json) describes the author feature's model-backed live runs. Those model runs were not repeated for this upstream stack. Its production Python tree and the V1, V2 session, V2 exclusive, and JavaScript probe harness files are byte-identical to author -commit `4df67a0`. The production digest therefore remains the concatenated +commit `58cd191`. The production digest is the concatenated `source_tree_sha256` groups recorded in that acceptance record. The upstream stack retains its foundation commits. Test-only differences mark @@ -23,12 +23,17 @@ no scanner gates are disabled. | Acceptance `372b84e` before the evidence-only integration merge | `bash ./scripts/doctor.sh` | 1011 passed, 0 failed; 92.62% line coverage; the same remaining doctor gates passed | | Acceptance `372b84e` | `bash ./scripts/conformance.sh` | 8 passed, 0 failed | | Acceptance `372b84e` | `bash ./scripts/live_opencode_v2_session_smoke.sh` | Isolated OpenCode 2.0.28, invalid Basic auth rejected, identity/location/session create/get/list passed; no model prompt submitted | +| Integration `a633808` | `bash ./scripts/doctor.sh` | 1012 passed, 0 failed; 92.65% line coverage; lint, format, secret scan, typing, coverage policy, package build and wheel smoke passed | +| Acceptance `bbddca7` before this QA-note update | `bash ./scripts/doctor.sh` | 1012 passed, 0 failed; 92.67% line coverage; the same remaining doctor gates passed | The author acceptance record includes the later 14-admission, single-SSE run -and its session-ownership and external-directory denial checks. Those live +and its session-ownership, external-directory denial, and inside-root workspace +override rejection checks. Exclusive execution uses one configured workspace; +default V2 session-only location mapping is unchanged. Those live results are author evidence, not a model-backed rerun of this upstream stack. Coverage percentages above describe individual runs; they do not replace the -author record's 92.66% observation at `e86042a`. Final-head reruns and their commit identities are +author record's 1012-test, 92.65% observation for the `58cd191` source digest. +Final-head reruns and their commit identities are reported with the integration handoff. No general shared-session V2 support, automatic recovery, or additional model-backed acceptance is claimed here. From a4f448fa2d9e9c452b24f8b0cd1e330197cc9887 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 19:15:26 +0100 Subject: [PATCH 25/30] fix(v2): require committed ownership before preferred-session reuse --- docs/evidence/v2-exclusive-acceptance.json | 9 ++--- docs/v2-exclusive-execution.md | 3 ++ src/opencode_a2a/execution/session_manager.py | 2 ++ tests/execution/test_v2_session_provenance.py | 36 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 tests/execution/test_v2_session_provenance.py diff --git a/docs/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json index ee37ddf2..b3dc6b0e 100644 --- a/docs/evidence/v2-exclusive-acceptance.json +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -1,7 +1,7 @@ { "verification_date": "2026-10-25", - "verified_at": "2026-10-25T18:49:17+01:00", - "source_tree_sha256": ["ee77d8d8", "b8e0d623", "9a121f81", "344c5def", "f171a7c7", "064ca29e", "c1e146d5", "4831daa7"], + "verified_at": "2026-10-25T19:13:14+01:00", + "source_tree_sha256": ["2145b838", "3378bcf9", "6fdf60ec", "8fa483cf", "7a2f8aa1", "d9868b2a", "7459e0c8", "aec3a3bc"], "source_hash_method": "Concatenate public digest groups. Input: sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", "scope": "Experimental private, gateway-created, single-flight sessions only; not general OpenCode V2 compatibility", "versions": { @@ -82,6 +82,7 @@ "Concurrent callbacks issue one native mutation", "Readiness timeout closes reader and releases stream capacity", "Unknown sessions and weakened permissions reject before prompt admission", + "Preferred IDs cannot claim a registered session before creator ownership is committed", "Idle registry retirement and active/quarantined execution budgets remain bounded" ], "unsupported": [ @@ -116,9 +117,9 @@ }, "deterministic_validation": { "doctor_command": "bash ./scripts/doctor.sh", - "tests_passed": 1012, + "tests_passed": 1013, "tests_failed": 0, - "line_coverage_percent": 92.65, + "line_coverage_percent": 92.62, "qa_tested_source": "source_tree_sha256", "critical_file_coverage_gates": "PASS", "typing_lint_format_build_wheel_smoke": "PASS", diff --git a/docs/v2-exclusive-execution.md b/docs/v2-exclusive-execution.md index 9a399dbe..ab874a60 100644 --- a/docs/v2-exclusive-execution.md +++ b/docs/v2-exclusive-execution.md @@ -50,6 +50,9 @@ state is necessary: previously saved grants are not trusted by this profile. Only sessions created by this running gateway are executable. Unknown, preexisting, imported, child, or post-restart session IDs cannot be adopted. +Preferred-session reuse additionally requires an existing owner binding under +the ownership lock; knowing a freshly allocated but not-yet-bound ID cannot +claim it before its creating request commits ownership. Persistent task history remains queryable after restart, but execution requires a new context/session. Follow-up in a healthy live session retains context. Execution uses the configured workspace only. Per-request location changes, diff --git a/src/opencode_a2a/execution/session_manager.py b/src/opencode_a2a/execution/session_manager.py index 14b6f1c6..27c68627 100644 --- a/src/opencode_a2a/execution/session_manager.py +++ b/src/opencode_a2a/execution/session_manager.py @@ -151,6 +151,8 @@ async def claim_preferred_session(self, *, identity: str, session_id: str) -> bo ownership_lock = await self._get_registered_lock(self._ownership_locks, session_id) async with ownership_lock: owner = await self._state_repository.get_owner(session_id=session_id) + if getattr(self._client, "supports_streaming_submission", False) is True and not owner: + raise PermissionError("Exclusive sessions require an existing owner binding") pending_owner = await self._state_repository.get_pending_claim(session_id=session_id) if owner and owner != identity: raise PermissionError(f"Session {session_id} is not owned by you") diff --git a/tests/execution/test_v2_session_provenance.py b/tests/execution/test_v2_session_provenance.py new file mode 100644 index 00000000..8234f090 --- /dev/null +++ b/tests/execution/test_v2_session_provenance.py @@ -0,0 +1,36 @@ +import pytest + +from tests.execution.test_v2_executor_cancellation import ExecutorServer + + +@pytest.mark.asyncio +async def test_registered_session_cannot_be_claimed_before_creator_ownership_is_committed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = ExecutorServer(monkeypatch) + try: + session = await server.client.create_session() + manager = server.executor._session_manager + repository = manager._state_repository + assert session in server.engine.sessions + assert await repository.get_owner(session_id=session) is None + before = len(server.requests) + with pytest.raises(PermissionError, match="existing owner binding"): + await manager.get_or_create_session( + "intruder", "foreign-context", "must not run", preferred_session_id=session + ) + assert len(server.requests) == before + assert await repository.get_pending_claim(session_id=session) is None + await repository.set_owner(session_id=session, identity="owner") + actual, pending = await manager.get_or_create_session( + "owner", "owner-context", "continue", preferred_session_id=session + ) + assert actual == session and pending is False + with pytest.raises(PermissionError, match="not owned"): + await manager.get_or_create_session( + "intruder", "another-context", "must not run", preferred_session_id=session + ) + assert await repository.get_owner(session_id=session) == "owner" + assert server.inputs == [] + finally: + await server.close() From b37a84983e7ed42b7a3d6d251b5bfcc1ffc44127 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 19:36:39 +0100 Subject: [PATCH 26/30] fix(v2): reject malformed reserved metadata without fallback --- docs/evidence/v2-exclusive-acceptance.json | 9 +++--- docs/v2-exclusive-execution.md | 4 +++ src/opencode_a2a/execution/executor.py | 32 ++++++++++++++++--- .../test_v2_exclusive_capabilities.py | 8 +++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/docs/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json index b3dc6b0e..686a629b 100644 --- a/docs/evidence/v2-exclusive-acceptance.json +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -1,7 +1,7 @@ { "verification_date": "2026-10-25", - "verified_at": "2026-10-25T19:13:14+01:00", - "source_tree_sha256": ["2145b838", "3378bcf9", "6fdf60ec", "8fa483cf", "7a2f8aa1", "d9868b2a", "7459e0c8", "aec3a3bc"], + "verified_at": "2026-10-25T19:31:05+01:00", + "source_tree_sha256": ["ecd2b57c", "fab35067", "04aeabac", "b8acdc58", "d4ddc9e6", "f4bd578b", "e691536e", "8cf3bef8"], "source_hash_method": "Concatenate public digest groups. Input: sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", "scope": "Experimental private, gateway-created, single-flight sessions only; not general OpenCode V2 compatibility", "versions": { @@ -83,6 +83,7 @@ "Readiness timeout closes reader and releases stream capacity", "Unknown sessions and weakened permissions reject before prompt admission", "Preferred IDs cannot claim a registered session before creator ownership is committed", + "Empty, null, numeric and malformed reserved metadata reject without default fallback or native HTTP", "Idle registry retirement and active/quarantined execution budgets remain bounded" ], "unsupported": [ @@ -117,9 +118,9 @@ }, "deterministic_validation": { "doctor_command": "bash ./scripts/doctor.sh", - "tests_passed": 1013, + "tests_passed": 1021, "tests_failed": 0, - "line_coverage_percent": 92.62, + "line_coverage_percent": 92.66, "qa_tested_source": "source_tree_sha256", "critical_file_coverage_gates": "PASS", "typing_lint_format_build_wheel_smoke": "PASS", diff --git a/docs/v2-exclusive-execution.md b/docs/v2-exclusive-execution.md index ab874a60..b21e06a1 100644 --- a/docs/v2-exclusive-execution.md +++ b/docs/v2-exclusive-execution.md @@ -99,6 +99,10 @@ cross-owner, expired, unsupported, and post-observation-loss replies reject before mutation. Callback eligibility is separate from native resolution events, so a successful answer can still produce the working transition. +Unsupported model/workspace metadata is rejected by field presence, including +empty, null, and wrong-typed values. Invalid session/directory metadata cannot +silently fall back to a fresh session or default model/location. + Unary text requests await the same proven execution outcome. **Unary interventions are unsupported**: an unexpected intervention fails and quarantines the session without approval. Rich/generic form kinds, `always` permission grants, diff --git a/src/opencode_a2a/execution/executor.py b/src/opencode_a2a/execution/executor.py index 1e5702c0..4316218c 100644 --- a/src/opencode_a2a/execution/executor.py +++ b/src/opencode_a2a/execution/executor.py @@ -56,6 +56,29 @@ _APPLICATION_JSON_MEDIA_TYPE = "application/json" +def _exclusive_metadata_rejected(sources: tuple[Mapping[str, object] | None, ...]) -> bool: + for source in sources: + if source is None: + continue + shared = source.get("shared", {}) + private = source.get("opencode", {}) + if not isinstance(shared, Mapping) or not isinstance(private, Mapping): + return True + if "model" in shared or "workspace" in private: + return True + if "session" in shared: + session = shared["session"] + if not isinstance(session, Mapping) or not isinstance(session.get("id"), str): + return True + if not session["id"].strip(): + return True + if "directory" in private: + directory = private["directory"] + if not isinstance(directory, str) or not directory.strip(): + return True + return False + + class OpencodeAgentExecutor(AgentExecutor): def __init__( self, @@ -226,16 +249,17 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non path=("directory",), ) if getattr(self._client, "supports_streaming_submission", False) is True and ( - model_provider_id is not None - or model_id is not None - or workspace_id is not None + _exclusive_metadata_rejected(metadata_source_tuple) or any(part.get("type") != "text" for part in request_parts) ): await self._emit_error( event_queue, task_id=task_id, context_id=context_id, - message="V2 exclusive mode supports text only, without model/workspace overrides.", + message=( + "V2 exclusive mode requires text and valid session/directory metadata, " + "without model/workspace overrides." + ), state=TaskState.TASK_STATE_FAILED, error_type="UPSTREAM_UNSUPPORTED_OPERATION", streaming_request=streaming_request, diff --git a/tests/contracts/test_v2_exclusive_capabilities.py b/tests/contracts/test_v2_exclusive_capabilities.py index 3991ac66..3419c26f 100644 --- a/tests/contracts/test_v2_exclusive_capabilities.py +++ b/tests/contracts/test_v2_exclusive_capabilities.py @@ -74,6 +74,14 @@ def upstream(request: httpx.Request) -> httpx.Response: {"shared": {"model": {"providerID": "openai", "modelID": "other"}}}, {"shared": {"model": {"modelID": "incomplete"}}}, {"opencode": {"workspace": {"id": "foreign"}}}, + {"shared": {"model": {}}}, + {"shared": {"model": None}}, + {"shared": {"model": {"providerID": 1, "modelID": 2}}}, + {"shared": {"session": {"id": 42}}}, + {"shared": {"session": {"id": ""}}}, + {"shared": []}, + {"opencode": {"workspace": {}}}, + {"opencode": {"directory": 42}}, ], ) async def test_exclusive_model_and_workspace_overrides_fail_before_upstream(metadata: dict) -> None: From 8f4fdb771fd473a2077f5b2e966642db8239d812 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 19:43:01 +0100 Subject: [PATCH 27/30] docs(v2): record ownership and metadata guard regression evidence --- docs/evidence/v2-exclusive-upstream-validation.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/evidence/v2-exclusive-upstream-validation.md b/docs/evidence/v2-exclusive-upstream-validation.md index 0b6e0cee..cff7285f 100644 --- a/docs/evidence/v2-exclusive-upstream-validation.md +++ b/docs/evidence/v2-exclusive-upstream-validation.md @@ -4,7 +4,7 @@ The [exclusive acceptance record](./v2-exclusive-acceptance.json) describes the author feature's model-backed live runs. Those model runs were not repeated for this upstream stack. Its production Python tree and the V1, V2 session, V2 exclusive, and JavaScript probe harness files are byte-identical to author -commit `58cd191`. The production digest is the concatenated +commit `792f0c7`. The production digest is the concatenated `source_tree_sha256` groups recorded in that acceptance record. The upstream stack retains its foundation commits. Test-only differences mark @@ -25,15 +25,19 @@ no scanner gates are disabled. | Acceptance `372b84e` | `bash ./scripts/live_opencode_v2_session_smoke.sh` | Isolated OpenCode 2.0.28, invalid Basic auth rejected, identity/location/session create/get/list passed; no model prompt submitted | | Integration `a633808` | `bash ./scripts/doctor.sh` | 1012 passed, 0 failed; 92.65% line coverage; lint, format, secret scan, typing, coverage policy, package build and wheel smoke passed | | Acceptance `bbddca7` before this QA-note update | `bash ./scripts/doctor.sh` | 1012 passed, 0 failed; 92.67% line coverage; the same remaining doctor gates passed | +| Integration `8111d76` | `bash ./scripts/doctor.sh` | 1021 passed, 0 failed; 92.66% line coverage; lint, format, secret scan, typing, coverage policy, package build and wheel smoke passed | +| Acceptance `b37a849` before this QA-note update | `bash ./scripts/doctor.sh` | 1021 passed, 0 failed; 92.68% line coverage; the same remaining doctor gates passed | The author acceptance record includes the later 14-admission, single-SSE run and its session-ownership, external-directory denial, and inside-root workspace override rejection checks. Exclusive execution uses one configured workspace; -default V2 session-only location mapping is unchanged. Those live +preferred-session reuse requires committed ownership, and malformed reserved +metadata is rejected without fallback. Default V2 session-only location mapping +is unchanged. Those live results are author evidence, not a model-backed rerun of this upstream stack. Coverage percentages above describe individual runs; they do not replace the -author record's 1012-test, 92.65% observation for the `58cd191` source digest. +author record's 1021-test, 92.66% observation for the `792f0c7` source digest. Final-head reruns and their commit identities are reported with the integration handoff. No general shared-session V2 support, automatic recovery, or additional model-backed acceptance is claimed here. From 6e38d107eb416914905e6d0515d916adb5ae6f2d Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 19:55:26 +0100 Subject: [PATCH 28/30] test(v2): separate SQLite startup from cancellation deadlines --- docs/evidence/v2-exclusive-acceptance.json | 10 +++++++++- tests/server/test_v2_cancel_http_boundary.py | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json index 686a629b..a70e6946 100644 --- a/docs/evidence/v2-exclusive-acceptance.json +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -127,7 +127,15 @@ "conformance_passed": 8, "conformance_failed": 0, "default_v2_session_smoke": "PASS", - "v1_repository_streaming_smoke": "PASS" + "v1_repository_streaming_smoke": "PASS", + "sdk_cancel_boundary_repeats": { + "runs": 8, + "tests_per_run": 6, + "failures": 0, + "startup_budget_seconds": 10, + "task_store_poll_interval_seconds": 0.01, + "semantic_cancellation_deadlines_changed": false + } }, "reproduction": "docs/v2-exclusive-execution.md#reproduce-the-live-evidence", "redactions": ["Credentials", "Raw prompts and model output", "Opaque session/task/request IDs", "Runtime paths and PIDs"] diff --git a/tests/server/test_v2_cancel_http_boundary.py b/tests/server/test_v2_cancel_http_boundary.py index 344e6a18..b349264f 100644 --- a/tests/server/test_v2_cancel_http_boundary.py +++ b/tests/server/test_v2_cancel_http_boundary.py @@ -94,7 +94,8 @@ async def start(self, http: httpx.AsyncClient) -> tuple[str, asyncio.Task[httpx. ) ) self.pending.append(stream) - async with asyncio.timeout(2): + # Real SQLite startup is not the cancellation deadline under test. + async with asyncio.timeout(10): while True: keys = set(executor._running_requests) - previous if keys: @@ -104,7 +105,7 @@ async def start(self, http: httpx.AsyncClient) -> tuple[str, asyncio.Task[httpx. return task_id, stream if stream.done(): raise AssertionError(f"Stream ended before execution: {(await stream).text}") - await asyncio.sleep(0.001) + await asyncio.sleep(0.01) @pytest_asyncio.fixture From dfa15f78352b9de9393a042caf7b7f68f5631edd Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 19:58:39 +0100 Subject: [PATCH 29/30] docs(v2): document separate SDK bootstrap budget and observed timeout --- .../v2-exclusive-upstream-validation.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/evidence/v2-exclusive-upstream-validation.md b/docs/evidence/v2-exclusive-upstream-validation.md index cff7285f..6fef6854 100644 --- a/docs/evidence/v2-exclusive-upstream-validation.md +++ b/docs/evidence/v2-exclusive-upstream-validation.md @@ -4,7 +4,7 @@ The [exclusive acceptance record](./v2-exclusive-acceptance.json) describes the author feature's model-backed live runs. Those model runs were not repeated for this upstream stack. Its production Python tree and the V1, V2 session, V2 exclusive, and JavaScript probe harness files are byte-identical to author -commit `792f0c7`. The production digest is the concatenated +commit `da51304`. The production digest is the concatenated `source_tree_sha256` groups recorded in that acceptance record. The upstream stack retains its foundation commits. Test-only differences mark @@ -41,3 +41,18 @@ author record's 1021-test, 92.66% observation for the `792f0c7` source digest. Final-head reruns and their commit identities are reported with the integration handoff. No general shared-session V2 support, automatic recovery, or additional model-backed acceptance is claimed here. + +## Bootstrap timeout mitigation + +An intermediate doctor run on acceptance `8f4fdb7` hit the existing two-second +bootstrap deadline while awaiting a real SQLite task-store GET, before the +cancellation operation. Its unchanged focused case and full doctor rerun passed. +The underlying host-delay cause is unknown. + +Author test-only commit `da51304`, applied to both upstream branches, separates +bootstrap from the cancellation behavior: bootstrap has a ten-second budget and +ten-millisecond polling instead of a two-second budget and one-millisecond +polling. The two-second cancellation and settlement assertion deadlines are +unchanged; there are no added retries, skips, or removed assertions. The author +acceptance JSON records eight consecutive six-case SDK runs (48 checks). +Production source and the six smoke/harness files are unchanged by this commit. From fab667a45bece2e79d1b531ba6ba4d8e5b2ad79c Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 25 Oct 2026 21:15:41 +0100 Subject: [PATCH 30/30] feat(config)!: default to V2 with explicit V1 compatibility Keep V2 execution disabled until the exclusive deployment acknowledgement is set. Exercise real defaults separately from explicitly selected V1 regression fixtures, and revalidate pinned V1/V2 live behavior. Refs https://github.com/tbrandenburg/opencode-a2a/issues/10 and https://github.com/Intelligent-Internet/opencode-a2a/issues/550 --- CONTRIBUTING.md | 5 ++ README.md | 14 +++- docs/compatibility.md | 22 +++-- docs/evidence/v2-default-acceptance.json | 59 +++++++++++++ docs/guide.md | 8 +- docs/v2-exclusive-execution.md | 5 +- scripts/README.md | 6 +- scripts/live_opencode_smoke.sh | 1 + scripts/live_opencode_v2_exclusive_probe.sh | 3 +- scripts/live_opencode_v2_session_smoke.sh | 3 +- src/opencode_a2a/cli.py | 11 ++- src/opencode_a2a/config.py | 2 +- tests/config/test_settings.py | 12 ++- tests/server/test_cli.py | 3 + tests/server/test_upstream_selection.py | 91 +++++++++++++++++++++ tests/support/settings.py | 2 + 16 files changed, 224 insertions(+), 23 deletions(-) create mode 100644 docs/evidence/v2-default-acceptance.json create mode 100644 tests/server/test_upstream_selection.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f66bb6a..7f9e0b9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,11 +34,16 @@ Then start the A2A runtime in another terminal: ```bash A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"dev-token","principal":"automation"}]' \ +OPENCODE_API_VERSION=v1 \ OPENCODE_BASE_URL=http://127.0.0.1:4096 \ OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \ uv run opencode-a2a serve ``` +This development example explicitly selects V1 compatibility mode. V2 is the +runtime default, but execution requires the opt-in and private deployment +preconditions in [docs/v2-exclusive-execution.md](docs/v2-exclusive-execution.md). + ## Validation Run the default validation baseline before opening a PR: diff --git a/README.md b/README.md index 4dcb6c92..84f351b7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ flowchart TD ## Quick Start +**Breaking default change:** V2 is now the default upstream API, but A2A +execution remains disabled until `OPENCODE_V2_EXECUTION=exclusive` is explicitly +set under the [private deployment contract](docs/v2-exclusive-execution.md). +This does not enable unrestricted shared sessions. Existing V1 deployments must +set `OPENCODE_API_VERSION=v1` before upgrading; there is no automatic fallback. +The quick start below uses that explicit V1 compatibility mode, not V2 deployment. + Install the released CLI with `uv tool`: ```bash @@ -74,6 +81,7 @@ Then start `opencode-a2a` against that upstream: ```bash DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \ +OPENCODE_API_VERSION=v1 \ OPENCODE_BASE_URL=http://127.0.0.1:4096 \ A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \ A2A_HOST=127.0.0.1 \ @@ -91,9 +99,9 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json ## Capabilities -The following capability list describes the default OpenCode API V1 mode. With -`OPENCODE_API_VERSION=v2`, only internal experimental session transport is -available: A2A message execution fails before upstream requests, streaming and +The following capability list describes explicit OpenCode API V1 compatibility +mode (`OPENCODE_API_VERSION=v1`). With the default V2 API, only internal +experimental session transport is available: A2A message execution fails before upstream requests, streaming and all extensions are disabled. A separate opt-in [`OPENCODE_V2_EXECUTION=exclusive` profile](docs/v2-exclusive-execution.md) adds restricted text execution and streaming read/question interventions in a diff --git a/docs/compatibility.md b/docs/compatibility.md index b60abe53..3bc28943 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -11,12 +11,18 @@ This document defines the compatibility promises `opencode-a2a` currently uphold - Experimental exclusive execution profile: OpenCode `2.0.28` only; see the [deployment contract](./v2-exclusive-execution.md). -By default, OpenCode V2 is an opt-in, experimental session-transport slice selected with -`OPENCODE_API_VERSION=v2`. It implements `/api/info`, `/api/location`, and +OpenCode V2 is now the default upstream API, with experimental session transport +only until execution is explicitly enabled. It implements `/api/info`, `/api/location`, and session create/get/list transport only; A2A prompt execution and all OpenCode-specific JSON-RPC extensions remain unavailable. This remains an explicit safety gate, not a claim that V2 prompt execution works. +**Breaking default change:** existing V1 deployments must set +`OPENCODE_API_VERSION=v1` before upgrading. There is no automatic detection or +fallback to V1. Explicit `OPENCODE_API_VERSION=v2` remains accepted, but is no +longer required. Default V2 is not a claim of V1 feature parity or a safe private +deployment: the execution acknowledgement and operator preconditions still apply. + The additional selector `OPENCODE_V2_EXECUTION=exclusive` enables a separately verified restricted profile: dedicated private upstream, gateway-created single-flight sessions, text execution, and streaming read/question @@ -59,11 +65,15 @@ exclusive profile's narrower evidence does not silently close those criteria. ### Exclusive-profile acceptance matrix -Evidence is recorded in [the sanitized acceptance record](./evidence/v2-exclusive-acceptance.json). +Fresh verification of default V2 and explicit V1 is recorded in +[the default-switch acceptance record](./evidence/v2-default-acceptance.json). +The [earlier acceptance record](./evidence/v2-exclusive-acceptance.json) describes +the previous V1-default source and its expanded manual matrix; it is historical, +not an exact-source claim for the changed defaults. The upstream stack's separate [regression validation record](./evidence/v2-exclusive-upstream-validation.md) -distinguishes its doctor and session-only checks from the author's model-backed runs. +distinguishes its earlier doctor and session-only checks from the author's model-backed runs. `PASS` applies only to the described tested subset; `NOT VERIFIED` is not a -support claim. V1 remains the stable default. Upstream review/merge and the +support claim. V1 remains available as explicit compatibility mode. Upstream review/merge and the epic's final scope decision are separate from these execution results. | Capability | V1 | V2 exclusive mode | Evidence / limitation | @@ -191,7 +201,7 @@ input-addressable terminal status, and make active cancellation input-scoped (or document an enforceable exclusive-session contract). Until then, ambiguous or disconnected outcomes must remain fail-closed. -V1 stays the default, and the adapter does not auto-detect or fall back between +V2 is the default, and the adapter does not auto-detect or fall back between versions. Reproduce the V2 session-transport check with `bash ./scripts/live_opencode_v2_session_smoke.sh`. It is pinned to OpenCode `2.0.28`, verifies Basic-auth rejection plus `/api/info`, `/api/location`, diff --git a/docs/evidence/v2-default-acceptance.json b/docs/evidence/v2-default-acceptance.json new file mode 100644 index 00000000..06ce1b4b --- /dev/null +++ b/docs/evidence/v2-default-acceptance.json @@ -0,0 +1,59 @@ +{ + "scope": "V2 is the default API; execution remains disabled without explicit exclusive acknowledgement; V1 requires explicit selection", + "source_tree_sha256": ["9650ee76", "46b5a371", "e45794c3", "0e900489", "8f806438", "9808a3e5", "b26385aa", "b1c8e4da"], + "source_hash_method": "Concatenate digest groups. Input: sorted src/opencode_a2a/**/*.py; relative path, NUL, file bytes, NUL", + "versions": { + "opencode_v1": "1.18.19", + "opencode_v2": "2.0.28", + "a2a_sdk": "1.2.3", + "observed_model": "openai/gpt-6-luna-fast" + }, + "configuration": { + "default_api": "v2", + "default_v2_execution": "disabled", + "explicit_v1": "OPENCODE_API_VERSION=v1", + "explicit_v2_execution": "OPENCODE_V2_EXECUTION=exclusive", + "automatic_detection_or_fallback": false, + "private_deployment_preconditions_unchanged": true + }, + "deterministic_validation": { + "doctor_command": "bash ./scripts/doctor.sh", + "tests_passed": 1026, + "tests_failed": 0, + "line_coverage_percent": 92.67, + "lint_format_typing_coverage_build_wheel_smoke": "PASS", + "conformance_passed": 8, + "conformance_failed": 0, + "environment_selection_and_disabled_execution_http_tests": "PASS" + }, + "fresh_live_validation": { + "v1_explicit_selection_auth_extensions_and_real_model_streaming_smoke": "PASS", + "v2_omitted_api_selector_session_transport_and_disabled_execution_setting": "PASS", + "v2_native_namespace_and_execution_probe": "PASS within its recorded subset", + "v2_gateway_api_selector_omitted": true, + "v2_exclusive_acknowledgement_explicit": true, + "v2_rest_jsonrpc_stream_unary_followup_and_persisted_terminal": "PASS", + "v2_concurrent_sessions_serialization_local_queued_and_active_cancel": "PASS", + "v2_read_allow_reject_question_string_multiselect_and_callback_denials": "PASS", + "v2_session_ownership_workspace_metadata_and_external_directory_denials": "PASS", + "v2_prompt_admissions": 14, + "v2_global_event_connections": 1, + "v2_negative_scenarios": [ + "gap", "lost-terminal", "lost-admission", "provider-failure", + "intervention-gap", "expired-intervention" + ], + "v2_negative_scenarios_passed": 6, + "v2_each_negative_admissions": 1, + "v2_each_negative_event_connections": 1, + "v2_negative_false_success_or_resubmission": false, + "live_reasoning_blocks": "NOT VERIFIED" + }, + "reproduction": { + "v1": "OPENCODE_BIN= OPENCODE_CONFIG_CONTENT='{\"model\":\"openai/gpt-6-luna-fast\"}' bash scripts/live_opencode_smoke.sh", + "v2_session": "bash scripts/live_opencode_v2_session_smoke.sh", + "v2_execution_and_faults": "docs/v2-exclusive-execution.md#reproduce-the-live-evidence" + }, + "limitations": "Private gateway-exclusive single-flight deployment only; no unrestricted sessions or transparent recovery; unsupported capabilities remain unchanged", + "historical_evidence": "v2-exclusive-acceptance.json describes the earlier V1-default source; it is not an exact-source claim for this default switch", + "redactions": ["Credentials", "Raw prompts/model output", "Opaque session/task/request IDs", "Runtime paths and PIDs"] +} diff --git a/docs/guide.md b/docs/guide.md index fc6cf635..0c84c4b1 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -29,7 +29,7 @@ Key variables to understand protocol behavior: - `A2A_STATIC_AUTH_CREDENTIALS`: required static credential registry in JSON array form. Supports multiple `bearer` / `basic` credentials, bearer-only required `principal`, optional `credential_id`, optional `capabilities`, and optional `enabled=false` for explicit disablement. - `OPENCODE_BASE_URL`: upstream OpenCode HTTP endpoint. Default: `http://127.0.0.1:4096`. In two-process deployments, set it explicitly. -- `OPENCODE_API_VERSION`: upstream API selector, `v1` (default) or `v2`, unrelated to the A2A SDK version. No auto-detection or fallback. V2 alone retains session-only transport and fails A2A execution before upstream HTTP. +- `OPENCODE_API_VERSION`: upstream API selector, `v2` (default) or explicit `v1`, unrelated to the A2A SDK version. **Breaking default change:** existing V1 deployments must set `OPENCODE_API_VERSION=v1` before upgrading. No auto-detection or fallback. Default V2 retains session-only transport and fails A2A execution before upstream HTTP until the exclusive profile is explicitly enabled. - `OPENCODE_V2_EXECUTION`: `disabled` (default) or `exclusive`. The latter opts into the [restricted V2 profile](./v2-exclusive-execution.md), requires a dedicated private single-process instance pair with protected configuration and durable ownership, and does not enable general shared-session support. Read its security preconditions before selecting it. - `OPENCODE_WORKSPACE_ROOT`: service-level default workspace root exposed to OpenCode when clients do not request a narrower directory override. - `A2A_ALLOW_DIRECTORY_OVERRIDE`: controls whether clients may pass `metadata.opencode.directory`. @@ -180,10 +180,13 @@ If your provider uses environment variables for auth, export them before startin Do not assume startup-script env vars always erase previously persisted OpenCode auth state for the deployed user. When debugging provider-auth surprises, inspect the deployed user's HOME/XDG config directories and the OpenCode files stored there before concluding that `opencode-a2a` changed the credential selection. -Then start `opencode-a2a` against that explicit upstream URL: +Then start `opencode-a2a` against that explicit upstream URL. These examples use +explicit V1 compatibility mode. Default V2 execution requires the separate +[exclusive deployment contract](./v2-exclusive-execution.md), not these shared-host examples: ```bash DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" +OPENCODE_API_VERSION=v1 \ OPENCODE_BASE_URL=http://127.0.0.1:4096 \ A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \ A2A_HOST=127.0.0.1 \ @@ -197,6 +200,7 @@ By default, the service uses a SQLite-backed durable state store: ```bash DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" +OPENCODE_API_VERSION=v1 \ OPENCODE_BASE_URL=http://127.0.0.1:4096 \ A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \ A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \ diff --git a/docs/v2-exclusive-execution.md b/docs/v2-exclusive-execution.md index b21e06a1..7e107bda 100644 --- a/docs/v2-exclusive-execution.md +++ b/docs/v2-exclusive-execution.md @@ -2,8 +2,9 @@ This is a restricted, opt-in execution profile for the pinned upstream version in [compatibility.md](./compatibility.md#runtime-support), not general V2 -shared-session support. V1 remains the default. Selecting only -`OPENCODE_API_VERSION=v2` still disables A2A execution. +shared-session support. V2 is the default upstream API; V1 requires +`OPENCODE_API_VERSION=v1`. Without `OPENCODE_V2_EXECUTION=exclusive`, A2A +execution remains disabled even when V2 is selected by default. ## Required deployment boundary diff --git a/scripts/README.md b/scripts/README.md index 25154320..17d27fdf 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -13,9 +13,9 @@ Executable scripts live in this directory. This file is the entry index for the - [`doctor.sh`](./doctor.sh): primary local development regression entrypoint with explicit fix/verify/package phases (uv sync + dependency compatibility + lint + mypy + tests + coverage + hash-locked PEP 517 build + built-wheel smoke test) - [`conformance.sh`](./conformance.sh): repository-owned A2A 1.0 black-box compatibility probes; can launch a dummy-backed local SUT and preserves machine-readable artifacts under `run/conformance/` -- [`live_opencode_smoke.sh`](./live_opencode_smoke.sh): local/manual live integration smoke against a real OpenCode runtime; boots an isolated password-hardened `opencode serve` plus the opencode-a2a runtime in throwaway directories and verifies upstream auth, JSON-RPC extension reads, and a streaming prompt round-trip -- [`live_opencode_v2_session_smoke.sh`](./live_opencode_v2_session_smoke.sh): pinned OpenCode 2.0.28 session-transport smoke; starts an isolated password-hardened server and exercises the application-selected V2 adapter's identity, location, and session create/get/list routes without submitting a model prompt -- [`live_opencode_v2_exclusive_probe.sh`](./live_opencode_v2_exclusive_probe.sh): disposable Docker namespace proof and optional real-model A2A exclusive-profile acceptance, including test-only fault injection; see [scope, prerequisites, and commands](../docs/v2-exclusive-execution.md#reproduce-the-live-evidence) +- [`live_opencode_smoke.sh`](./live_opencode_smoke.sh): local/manual live integration smoke with explicit V1 opt-in against a real OpenCode runtime; boots an isolated password-hardened `opencode serve` plus the opencode-a2a runtime in throwaway directories and verifies upstream auth, JSON-RPC extension reads, and a streaming prompt round-trip +- [`live_opencode_v2_session_smoke.sh`](./live_opencode_v2_session_smoke.sh): pinned OpenCode 2.0.28 session-transport smoke; omits the API selector to exercise default V2 and its disabled execution gate, then checks identity, location, and session create/get/list routes without submitting a model prompt +- [`live_opencode_v2_exclusive_probe.sh`](./live_opencode_v2_exclusive_probe.sh): disposable Docker namespace proof and optional real-model A2A acceptance with default V2 plus explicit exclusive acknowledgement, including test-only fault injection; see [scope, prerequisites, and commands](../docs/v2-exclusive-execution.md#reproduce-the-live-evidence) - [`dependency_health.sh`](./dependency_health.sh): development and build dependency review entrypoint (`sync`/`pip check` + outdated + strict hash-locked audits), while blocking CI/publish audits focus on runtime dependencies - [`check_coverage.py`](./check_coverage.py): enforces the overall coverage floor and per-file minimums for critical modules - [`find_thin_wrappers.py`](./find_thin_wrappers.py): static analysis helper that reports local functions with low caller counts and flags likely thin forwarding wrappers for manual abstraction review diff --git a/scripts/live_opencode_smoke.sh b/scripts/live_opencode_smoke.sh index 57b0b9e5..7cf19749 100755 --- a/scripts/live_opencode_smoke.sh +++ b/scripts/live_opencode_smoke.sh @@ -195,6 +195,7 @@ if [[ ! -x "${adapter_bin}" ]]; then exit 1 fi +OPENCODE_API_VERSION=v1 \ OPENCODE_BASE_URL="${upstream_url}" \ OPENCODE_WORKSPACE_ROOT="${workspace_dir}" \ OPENCODE_AUTH_USERNAME="${upstream_username}" \ diff --git a/scripts/live_opencode_v2_exclusive_probe.sh b/scripts/live_opencode_v2_exclusive_probe.sh index d910721a..b288b878 100644 --- a/scripts/live_opencode_v2_exclusive_probe.sh +++ b/scripts/live_opencode_v2_exclusive_probe.sh @@ -85,7 +85,8 @@ if [[ "${V2_PROBE_A2A:-0}" == 1 ]]; then node /repo/scripts/probes/v2_fault_proxy.mjs docker exec -d --env "V2_PROBE_CASE=${V2_PROBE_CASE:-normal}" "${container}" sh -c ' export UV_PROJECT_ENVIRONMENT=/probe/venv UV_CACHE_DIR=/probe/uv-cache - export OPENCODE_API_VERSION=v2 OPENCODE_V2_EXECUTION=exclusive + unset OPENCODE_API_VERSION + export OPENCODE_V2_EXECUTION=exclusive export OPENCODE_BASE_URL=http://127.0.0.1:4097 OPENCODE_WORKSPACE_ROOT=/probe/workspace export OPENCODE_TIMEOUT_STREAM=30 if [ "${V2_PROBE_CASE}" = expired-intervention ]; then export A2A_INTERRUPT_REQUEST_TTL_SECONDS=1; fi diff --git a/scripts/live_opencode_v2_session_smoke.sh b/scripts/live_opencode_v2_session_smoke.sh index a2423f01..56d53c52 100644 --- a/scripts/live_opencode_v2_session_smoke.sh +++ b/scripts/live_opencode_v2_session_smoke.sh @@ -206,7 +206,6 @@ async def main() -> None: workspace = Path(credentials["workspace"]) settings = Settings( _env_file=None, - opencode_api_version="v2", opencode_base_url=credentials["base_url"], opencode_workspace_root=str(workspace), opencode_auth_username=credentials["username"], @@ -222,6 +221,8 @@ async def main() -> None: ) app = create_app(settings) upstream = app.state.upstream_client + if settings.opencode_api_version != "v2" or settings.opencode_v2_execution != "disabled": + raise RuntimeError("Default V2 transport must retain the execution opt-in gate") if not isinstance(upstream, V2UpstreamAdapter): raise RuntimeError("Application did not select the V2 upstream adapter") diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 80666f87..91743eab 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -54,6 +54,11 @@ " JSON array with at least one enabled bearer/basic credential.\n" "\n" "Serve common environment:\n" + " OPENCODE_API_VERSION\n" + " v2 (default) or explicit v1 compatibility mode. No automatic fallback.\n" + " OPENCODE_V2_EXECUTION\n" + " disabled (default) or exclusive. V2 execution requires explicit exclusive opt-in\n" + " and a private isolated instance pair; see docs/v2-exclusive-execution.md.\n" " OPENCODE_BASE_URL\n" " Upstream opencode serve URL. Default: http://127.0.0.1:4096\n" " A2A_HOST\n" @@ -69,19 +74,21 @@ " OPENCODE_WORKSPACE_ROOT\n" " Workspace root exposed to OpenCode tool execution.\n" "\n" - "Serve minimal example:\n" + "Serve minimal example (explicit V1 compatibility mode):\n" " DEMO_BEARER_TOKEN=\"$(python3 -c 'import secrets; print(secrets.token_hex(24))')\"\n" " A2A_STATIC_AUTH_CREDENTIALS=" '\'[{"scheme":"bearer","token":"\'"${DEMO_BEARER_TOKEN}"\'","principal":"automation"}]\'' " \\\n" + " OPENCODE_API_VERSION=v1 \\\n" " OPENCODE_BASE_URL=http://127.0.0.1:4096 \\\n" " opencode-a2a serve\n" "\n" - "Serve durable SQLite example:\n" + "Serve durable SQLite example (explicit V1 compatibility mode):\n" " DEMO_BEARER_TOKEN=\"$(python3 -c 'import secrets; print(secrets.token_hex(24))')\"\n" " A2A_STATIC_AUTH_CREDENTIALS=" '\'[{"scheme":"bearer","token":"\'"${DEMO_BEARER_TOKEN}"\'","principal":"automation"}]\'' " \\\n" + " OPENCODE_API_VERSION=v1 \\\n" " OPENCODE_BASE_URL=http://127.0.0.1:4096 \\\n" " A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \\\n" " OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \\\n" diff --git a/src/opencode_a2a/config.py b/src/opencode_a2a/config.py index 9c39ccaf..6505e866 100644 --- a/src/opencode_a2a/config.py +++ b/src/opencode_a2a/config.py @@ -149,7 +149,7 @@ class Settings(BaseSettings): ) # OpenCode settings - opencode_api_version: OpencodeApiVersion = Field(default="v1", alias="OPENCODE_API_VERSION") + opencode_api_version: OpencodeApiVersion = Field(default="v2", alias="OPENCODE_API_VERSION") opencode_v2_execution: Literal["disabled", "exclusive"] = Field( default="disabled", alias="OPENCODE_V2_EXECUTION" ) diff --git a/tests/config/test_settings.py b/tests/config/test_settings.py index 65fcf041..e0a4ae04 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -24,9 +24,16 @@ def test_settings_missing_required(): def test_settings_use_bounded_upstream_defaults() -> None: - settings = make_settings() + with mock.patch.dict(os.environ, {}, clear=True): + settings = Settings( + _env_file=None, + a2a_static_auth_credentials=( + {"scheme": "bearer", "token": "test-token", "principal": "automation"}, + ), + ) - assert settings.opencode_api_version == "v1" + assert settings.opencode_api_version == "v2" + assert settings.opencode_v2_execution == "disabled" assert settings.opencode_timeout_stream == 900.0 assert settings.opencode_max_concurrent_requests == 32 assert settings.opencode_max_concurrent_streams == 8 @@ -164,6 +171,7 @@ def test_settings_accepts_explicit_opencode_api_version(version: str) -> None: settings = Settings() assert settings.opencode_api_version == version + assert settings.opencode_v2_execution == "disabled" def test_settings_rejects_unknown_opencode_api_version() -> None: diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 25e69690..91b402df 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -202,6 +202,9 @@ def test_cli_serve_help_prints_serve_specific_examples( help_text = capsys.readouterr().out assert "Run the OpenCode A2A service. A2A Protocol 1.0 only." in help_text assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text + assert "v2 (default) or explicit v1 compatibility mode" in help_text + assert "V2 execution requires explicit exclusive opt-in" in help_text + assert "OPENCODE_API_VERSION=v1" in help_text assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text assert "A2A_CLIENT_BEARER_TOKEN=peer-token" not in help_text serve_mock.assert_not_called() diff --git a/tests/server/test_upstream_selection.py b/tests/server/test_upstream_selection.py new file mode 100644 index 00000000..5595d13e --- /dev/null +++ b/tests/server/test_upstream_selection.py @@ -0,0 +1,91 @@ +import os +from pathlib import Path +from unittest import mock + +import httpx +import pytest + +from opencode_a2a.config import Settings +from opencode_a2a.opencode_upstream_client import OpencodeUpstreamClient +from opencode_a2a.server.application import create_app +from opencode_a2a.upstream.v2 import V2UpstreamAdapter + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("environment", "version", "execution"), + [ + ({}, "v2", False), + ({"OPENCODE_API_VERSION": "v2"}, "v2", False), + ({"OPENCODE_V2_EXECUTION": "exclusive"}, "v2", True), + ({"OPENCODE_API_VERSION": "v1"}, "v1", True), + ], +) +async def test_environment_selects_adapter_and_discovery( + environment: dict[str, str], version: str, execution: bool, tmp_path: Path +) -> None: + with mock.patch.dict(os.environ, environment, clear=True): + settings = Settings( + _env_file=None, + a2a_static_auth_credentials=( + {"scheme": "bearer", "token": "test-token", "principal": "automation"}, + ), + a2a_task_store_database_url=f"sqlite+aiosqlite:///{tmp_path}/tasks.db", + opencode_workspace_root=str(tmp_path), + opencode_auth_password="synthetic-selection-password", # pragma: allowlist secret + ) + + app = create_app(settings) + expected = V2UpstreamAdapter if version == "v2" else OpencodeUpstreamClient + assert settings.opencode_api_version == version + assert type(app.state.upstream_client) is expected + assert getattr(app.state.upstream_client, "prompt_execution_supported", True) is execution + + requests: list[httpx.Request] = [] + + def unexpected_upstream(request: httpx.Request) -> httpx.Response: + requests.append(request) + raise AssertionError("Discovery and disabled execution must not contact upstream") + + app.state.upstream_client._client._transport = httpx.MockTransport(unexpected_upstream) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": "Bearer test-token"}, + ) as client: + card = await client.get("/.well-known/agent-card.json") + assert card.status_code == 200 + assert card.json()["capabilities"]["streaming"] is execution + if not execution: + assert not card.json()["capabilities"].get("extensions") + failed = await client.post( + "/message:send", + json={ + "message": { + "messageId": "default-v2-disabled", + "role": "ROLE_USER", + "parts": [{"text": "must not execute"}], + } + }, + ) + assert failed.status_code == 200 + task = failed.json()["task"] + assert task["status"]["state"] == "TASK_STATE_FAILED" + assert "No prompt was submitted" in task["status"]["message"]["parts"][0]["text"] + assert requests == [] + + +def test_default_exclusive_profile_still_rejects_missing_upstream_credentials() -> None: + with mock.patch.dict(os.environ, {"OPENCODE_V2_EXECUTION": "exclusive"}, clear=True): + settings = Settings( + _env_file=None, + a2a_static_auth_credentials=( + {"scheme": "bearer", "token": "test-token", "principal": "automation"}, + ), + ) + + assert settings.opencode_api_version == "v2" + assert settings.opencode_v2_execution == "exclusive" + with pytest.raises(ValueError, match="separate upstream Basic credentials"): + create_app(settings) diff --git a/tests/support/settings.py b/tests/support/settings.py index 3760bfd8..04eb3959 100644 --- a/tests/support/settings.py +++ b/tests/support/settings.py @@ -72,6 +72,8 @@ def _build_test_static_auth_credentials(**overrides: Any) -> tuple[dict[str, Any def make_settings(**overrides: Any) -> Settings: base: dict[str, Any] = { + # Existing regression fixtures exercise V1 explicitly, not the runtime default. + "opencode_api_version": "v1", "opencode_base_url": "http://127.0.0.1:4096", "a2a_task_store_database_url": ( f"sqlite+aiosqlite:///{tempfile.gettempdir()}/opencode-a2a-test-{uuid.uuid4().hex}.db"