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/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 7b52ff57..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,6 +99,17 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json ## Capabilities +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 +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 39117a43..3bc28943 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -5,9 +5,214 @@ 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`) +- Experimental exclusive execution profile: OpenCode `2.0.28` only; see the + [deployment contract](./v2-exclusive-execution.md). + +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 +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 +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 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 + +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 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 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 | +| --- | --- | --- | --- | +| 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. + +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`, +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/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." +} 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/evidence/v2-exclusive-acceptance.json b/docs/evidence/v2-exclusive-acceptance.json new file mode 100644 index 00000000..a70e6946 --- /dev/null +++ b/docs/evidence/v2-exclusive-acceptance.json @@ -0,0 +1,142 @@ +{ + "verification_date": "2026-10-25", + "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": { + "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", + "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"], + "reasoning_blocks_live": "NOT VERIFIED" + }, + "v2_live_http_counts": { + "prompt_admissions": 14, + "global_event_connections": 1, + "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, + "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", + "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": [ + "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": 1021, + "tests_failed": 0, + "line_coverage_percent": 92.66, + "qa_tested_source": "source_tree_sha256", + "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", + "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/docs/evidence/v2-exclusive-upstream-validation.md b/docs/evidence/v2-exclusive-upstream-validation.md new file mode 100644 index 00000000..6fef6854 --- /dev/null +++ b/docs/evidence/v2-exclusive-upstream-validation.md @@ -0,0 +1,58 @@ +# 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 `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 +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 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 + +| 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 | +| 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; +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 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. 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 fb4bc0ce..0c84c4b1 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -29,6 +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`: 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`. - `A2A_ENABLE_SESSION_SHELL`: gates high-risk JSON-RPC method `opencode.sessions.shell`. @@ -178,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 \ @@ -195,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/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/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..7e107bda --- /dev/null +++ b/docs/v2-exclusive-execution.md @@ -0,0 +1,143 @@ +# 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. 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 + +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. +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, +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 +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. + +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, +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/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..17d27fdf 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -13,7 +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_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 e33c227f..7cf19749 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 @@ -189,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}" \ @@ -208,7 +215,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 +229,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 +270,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 +279,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..b288b878 --- /dev/null +++ b/scripts/live_opencode_v2_exclusive_probe.sh @@ -0,0 +1,100 @@ +#!/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" +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" +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 + 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 + 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 100644 index 00000000..56d53c52 --- /dev/null +++ b/scripts/live_opencode_v2_session_smoke.sh @@ -0,0 +1,256 @@ +#!/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_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 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") + + 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..1bc56ae7 --- /dev/null +++ b/scripts/probes/v2_a2a.mjs @@ -0,0 +1,289 @@ +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"); +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"); +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"; +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("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; + 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")); +} +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 => { + 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, 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_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..c99b30d0 --- /dev/null +++ b/scripts/probes/v2_fault_proxy.mjs @@ -0,0 +1,92 @@ +// 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: [], 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") { + 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.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") { + 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"); 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 84f1775f..6505e866 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,10 @@ class Settings(BaseSettings): ) # OpenCode settings + opencode_api_version: OpencodeApiVersion = Field(default="v2", 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/__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..b3819e04 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,119 @@ 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] + v2_exclusive_execution: bool = False + + @property + def extensions_enabled(self) -> bool: + 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" or self.v2_exclusive_execution + + @property + def streaming_enabled(self) -> bool: + 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) if conditional_method is None: return True return conditional_method.enabled def session_management_methods(self) -> dict[str, str]: + 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"]): 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 or self.v2_exclusive_execution: + 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 or self.v2_exclusive_execution: + 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 or self.v2_exclusive_execution: + 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 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") + 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 self.v2_exclusive_execution: + return [ + *catalog.INTERRUPT_RECOVERY_METHODS.values(), + *catalog.INTERRUPT_CALLBACK_METHODS.values(), + ] + 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 +195,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 +204,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 +213,20 @@ 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", + v2_exclusive_execution: bool = False, +) -> JsonRpcCapabilitySnapshot: conditional_methods = { catalog.SESSION_CONTROL_METHODS["shell"]: DeploymentConditionalMethod( method=catalog.SESSION_CONTROL_METHODS["shell"], @@ -139,4 +246,8 @@ 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), + 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 328b751c..9a15fa80 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,56 +239,73 @@ 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), - "default_protocol_version": declared_default_protocol_version, - "supported_protocol_versions": declared_supported_protocol_versions, - "protocol_compatibility": protocol_compatibility, - "core": _build_core_contract_surface(), - "extension_retention": _build_extension_retention(), - "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." - ), + 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." @@ -296,7 +314,18 @@ def build_compatibility_profile_params( "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, + "supported_protocol_versions": declared_supported_protocol_versions, + "protocol_compatibility": protocol_compatibility, + "core": _build_core_contract_surface(), + "extension_retention": _build_extension_retention(capability_snapshot), + "method_retention": _build_method_retention(capability_snapshot), + "service_behaviors": service_behaviors, + "consumer_guidance": consumer_guidance, } @@ -350,6 +379,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 +395,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 +415,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/execution/coordinator.py b/src/opencode_a2a/execution/coordinator.py index e463f96c..01f2d0ff 100644 --- a/src/opencode_a2a/execution/coordinator.py +++ b/src/opencode_a2a/execution/coordinator.py @@ -22,9 +22,23 @@ TaskStatusUpdateEvent, ) -from ..opencode_upstream_client import UpstreamConcurrencyLimitError, UpstreamContractError +from ..opencode_upstream_client import ( + OpencodeMessage, + UpstreamConcurrencyLimitError, + UpstreamContractError, +) +from ..upstream.contracts import ( + ExecutionOutcome, + ExecutionState, + 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,12 +50,14 @@ _await_stream_terminal_signal, _format_upstream_error, _StreamTerminalSignal, + _UpstreamInBandError, ) if TYPE_CHECKING: from .executor import OpencodeAgentExecutor logger = logging.getLogger(__name__) +_MISSING = object() @dataclass(frozen=True) @@ -107,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) @@ -116,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) @@ -134,19 +158,46 @@ 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: + 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 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 + + upstream: UpstreamAdapter = self._executor._client + submit_prompt = getattr(upstream, "submit_prompt", _MISSING) + response: ExecutionOutcome | OpencodeMessage + if submit_prompt is _MISSING: response = await self._executor._client.send_message( self._session_id, - user_text, - **send_kwargs, + prompt_text, + **prompt_kwargs, ) else: - response = await self._executor._client.send_message( + if not callable(submit_prompt): + raise UpstreamContractError( + "OpenCode submit_prompt must be callable when provided" + ) + submission = await submit_prompt( self._session_id, - user_text or None, - parts=turn_request_parts, - **send_kwargs, + 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 if self._pending_preferred_claim: await self._executor._session_manager.finalize_preferred_session_binding( @@ -156,8 +207,22 @@ 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) + else response.raw + ) + if self._execution_key in self._executor._cancelled_requests: + break tool_results = await maybe_handle_tools( - response.raw, + response_payload, a2a_client_manager=self._executor._a2a_client_manager, ) if tool_results: @@ -250,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 @@ -269,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 @@ -300,18 +367,53 @@ async def _bind_session(self) -> None: ) async def _handle_response(self, response: Any) -> None: - response_text = response.text or "" + if self._execution_key in self._executor._cancelled_requests: + return + 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 | 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, + ) + 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 + ): + 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( - _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 +433,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 +440,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 +449,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 +538,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 +569,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, @@ -507,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 19fffcd2..4316218c 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__) @@ -53,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, @@ -88,6 +114,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] = {} @@ -109,6 +137,23 @@ 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=unsupported_reason, + 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 @@ -203,6 +248,23 @@ 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 ( + _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 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, + ) + return directory: str | None = None if workspace_id is None: @@ -305,6 +367,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() @@ -326,12 +391,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: @@ -420,12 +492,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, @@ -438,6 +583,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..27c68627 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) @@ -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/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..66746031 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( @@ -405,112 +412,108 @@ 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: 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 +522,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 +564,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 +591,7 @@ def _tool_chunks( ) ) - delta = props.get("delta") + delta = update.delta if isinstance(delta, str) and delta: chunks.extend( _delta_chunks( @@ -605,12 +605,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/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index 21087464..339c544d 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,7 +63,7 @@ def __init__( http_handler, upstream_client: OpencodeUpstreamClient, methods: dict[str, str], - supported_methods: list[str], + 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, @@ -76,6 +76,7 @@ def __init__( super().__init__(request_handler=http_handler, **kwargs) self._http_handler = http_handler self._upstream_client = upstream_client + 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"] @@ -109,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 ( @@ -350,7 +351,11 @@ 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 + and self._capability_snapshot.supports_extension(value) ) return JSONResponse( { @@ -422,6 +427,10 @@ async def handle_requests(self, request: Request) -> Response: return await super().handle_requests(request) 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/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/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index e7032cc4..53bfa143 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -63,9 +63,26 @@ 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 not capability_snapshot.a2a_message_execution_enabled: + return ( + f"{base} Only internal experimental OpenCode V2 session transport exists; " + "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 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, @@ -172,8 +189,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, ) @@ -184,26 +204,34 @@ 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, ) + 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, + 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 = [ @@ -279,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( @@ -347,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] @@ -356,6 +394,30 @@ 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( + 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. " + "SendMessage and SendStreamingMessage fail before any upstream request." + ), + tags=["status"], + ) + ] if not include_detailed_contracts: return [ AgentSkill( @@ -480,9 +542,15 @@ 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, + v2_exclusive_execution=settings.opencode_v2_execution == "exclusive", + ) security_schemes: dict[str, SecurityScheme] = {} security_requirements: list[SecurityRequirement] = [] configured_auth_schemes = { @@ -505,13 +573,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, @@ -528,13 +595,18 @@ 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=True, + streaming=capability_snapshot.streaming_enabled, extended_agent_card=True, extensions=_build_agent_extensions( runtime_profile=runtime_profile, + capability_snapshot=capability_snapshot, include_detailed_contracts=include_detailed_contracts, ), ), diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index 431a8850..456d11ee 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -23,12 +23,7 @@ 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 ( @@ -38,6 +33,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,13 +168,26 @@ 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, ) 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, + v2_exclusive_execution=settings.opencode_v2_execution == "exclusive", + ) + 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, @@ -202,23 +211,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=capability_snapshot.supported_jsonrpc_methods(), + capability_snapshot=capability_snapshot, directory_resolver=( partial( executor._sandbox_policy.resolve_directory, @@ -349,12 +349,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 176693cf..ffcb9e8b 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": @@ -485,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)) @@ -494,7 +503,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 not capability_snapshot.extensions_enabled: + 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 @@ -504,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 4e305603..1e342dfa 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,23 @@ 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 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, " + "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,8 +261,13 @@ def _patch_jsonrpc_openapi_contract( settings: Settings, *, runtime_profile: RuntimeProfile, + capability_snapshot: JsonRpcCapabilitySnapshot | None = None, ) -> None: - del settings + 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, ) @@ -276,46 +299,56 @@ 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", + ), ), - ), - }, - } + }, + } + 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) request_body = post.setdefault("requestBody", {}) if isinstance(request_body, dict): @@ -325,7 +358,16 @@ 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() + ) + 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": { @@ -347,6 +389,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) @@ -369,7 +419,18 @@ 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 + 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/__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..8b517951 --- /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 | None + + +@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/src/opencode_a2a/upstream/v2.py b/src/opencode_a2a/upstream/v2.py new file mode 100644 index 00000000..2ae2d07d --- /dev/null +++ b/src/opencode_a2a/upstream/v2.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import replace +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 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): + """Raised when the selected upstream protocol has no implemented operation.""" + + +class V2UpstreamAdapter(OpencodeUpstreamClient): + """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, + 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() + 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(): + raise UnsupportedUpstreamOperationError( + "OpenCode V2 session transport does not support V1 workspace IDs." + ) + 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: + 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 self.prompt_execution_supported: + await self._verify_exclusive_profile(resolved_directory) + payload["permissions"] = exclusive_permissions() + if title: + payload["title"] = title + 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, + 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(self.prompt_execution_unsupported_reason) + + 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 = ..., + stream_updates_requested: bool = False, + ) -> PromptSubmission: + 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_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/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/config/test_settings.py b/tests/config/test_settings.py index b9ef5cd5..e0a4ae04 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -24,8 +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 == "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 @@ -151,6 +159,33 @@ 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 + assert settings.opencode_v2_execution == "disabled" + + +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/contracts/test_extension_contract_consistency.py b/tests/contracts/test_extension_contract_consistency.py index a2ed109c..3f9d3262 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,111 @@ 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) + 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) + + @pytest.mark.asyncio @pytest.mark.parametrize( ("method", "params", "interrupt_type"), diff --git a/tests/contracts/test_v2_exclusive_capabilities.py b/tests/contracts/test_v2_exclusive_capabilities.py new file mode 100644 index 00000000..3419c26f --- /dev/null +++ b/tests/contracts/test_v2_exclusive_capabilities.py @@ -0,0 +1,131 @@ +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", # pragma: allowlist secret + ) + 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"}}}, + {"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: + app = create_app( + make_settings( + opencode_api_version="v2", + opencode_v2_execution="exclusive", + opencode_workspace_root="/workspace", + opencode_auth_password="synthetic-upstream-password", # pragma: allowlist secret + ) + ) + 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_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_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_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 997c48d3..910136bb 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,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 ( + ExecutionOutcome, + ExecutionState, + PromptReceipt, + PromptSubmission, + SessionRef, +) from tests.support.helpers import ( DummyEventQueue, make_request_context, @@ -45,6 +53,145 @@ 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.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( diff --git a/tests/execution/test_v2_executor_cancellation.py b/tests/execution/test_v2_executor_cancellation.py new file mode 100644 index 00000000..5df16cc4 --- /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", # pragma: allowlist secret + ) + ) + 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 new file mode 100644 index 00000000..9e0e9cbb --- /dev/null +++ b/tests/execution/test_v2_prompt_failfast.py @@ -0,0 +1,38 @@ +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 + 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() + + 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 "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/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() diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index 9599c539..847c664b 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -1,10 +1,20 @@ import httpx import pytest +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: @@ -36,6 +46,158 @@ 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() + 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={ + "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 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 + 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 +@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 "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 == [] + + @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..6ed2a94d 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -60,6 +60,39 @@ 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 "Only internal experimental OpenCode V2 session transport exists" in card.description + assert ( + "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] + 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: card = build_agent_card( make_settings( 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 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/server/test_v2_cancel_http_boundary.py b/tests/server/test_v2_cancel_http_boundary.py new file mode 100644 index 00000000..b349264f --- /dev/null +++ b/tests/server/test_v2_cancel_http_boundary.py @@ -0,0 +1,205 @@ +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", # 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, + ) + ) + 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) + # 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: + 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.01) + + +@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/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" 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..a67852bb --- /dev/null +++ b/tests/upstream/test_upstream_contracts.py @@ -0,0 +1,128 @@ +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.outcome is not None + 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 is not None + 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() 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_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_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() diff --git a/tests/upstream/test_v2_profile.py b/tests/upstream/test_v2_profile.py new file mode 100644 index 00000000..a18fb186 --- /dev/null +++ b/tests/upstream/test_v2_profile.py @@ -0,0 +1,82 @@ +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, + 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" # pragma: allowlist secret + 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"} + + +@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() 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 new file mode 100644 index 00000000..48d0bc43 --- /dev/null +++ b/tests/upstream/test_v2_upstream_adapter.py @@ -0,0 +1,261 @@ +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 +@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, + 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: + 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="does not prove a per-input terminal outcome", + ): + await client.send_message("ses-1", "hello") + with pytest.raises( + UnsupportedUpstreamOperationError, + match="does not prove a per-input terminal outcome", + ): + 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"]