diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..fbaeeb6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,43 @@ +name: Bug report +description: Report a reproducible runtime, protocol, or documentation issue +title: "[Bug] " +labels: [bug] +body: + - type: markdown + attributes: + value: Remove secrets and sensitive data from logs and protocol payloads before submitting. + - type: textarea + id: problem + attributes: + label: Problem description + description: What happened, and what behavior did you expect? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Reproduction steps + description: Provide minimal, repeatable steps and sanitized requests. + validations: + required: true + - type: input + id: version + attributes: + label: Version and environment + description: Include the opencode-a2a, OpenCode, Python, and operating system versions. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Supporting evidence + description: Include sanitized logs, error responses, or relevant links. + - type: checkboxes + id: checks + attributes: + label: Pre-submission checklist + options: + - label: I searched the open issues and found no duplicate report. + required: true + - label: I confirm that this submission contains no tokens, passwords, or `.env` contents. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..163d6dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability reporting + url: https://github.com/Intelligent-Internet/opencode-a2a/blob/main/SECURITY.md + about: Read the private disclosure guidance before reporting sensitive information. Do not disclose it in a public issue. + - name: Support scope + url: https://github.com/Intelligent-Internet/opencode-a2a/blob/main/SUPPORT.md + about: Review the supported scope before opening an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7757ebd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature request +description: Propose a new A2A, OpenCode, or engineering capability +title: "[Feature] " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: User problem + description: What specific problem needs to be solved? Avoid describing only an implementation. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the expected behavior, protocol or extension boundary, and alternatives. + validations: + required: true + - type: dropdown + id: surface + attributes: + label: Affected surface + options: + - A2A core / transport + - OpenCode provider-private extension + - Operations / observability + - Documentation / open source engineering + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Pre-submission checklist + options: + - label: I searched the open issues and found no duplicate request. + required: true + - label: I explained whether this capability belongs to A2A core, a shared extension, or a provider-private surface. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6ad361b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +Describe the problem, solution, and user-visible impact. + +## Related issue + +Closes # + +## Validation + +- [ ] Ran `./scripts/doctor.sh` +- [ ] Ran `./scripts/conformance.sh` when changing an A2A transport or contract +- [ ] Verified that the Agent Card, OpenAPI, machine-readable contracts, and documentation remain synchronized +- [ ] Confirmed that logs, test data, and commits contain no secrets or `.env` contents + +## Compatibility and risk + +Describe protocol, SDK or Python support boundaries, deployment impact, and security considerations. Write "None" when not applicable. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ba68bfb..cd2ca1d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - name: Set up uv uses: astral-sh/setup-uv@v7 @@ -66,7 +66,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cddc89..4275b6d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ This repository maintains an OpenCode A2A runtime. Changes should keep runtime b Requirements: -- Python 3.11, 3.12, or 3.13 +- Python 3.11, 3.12, 3.13, or 3.14 - `uv` - A reachable OpenCode runtime if you need end-to-end manual checks @@ -56,13 +56,13 @@ bash -n scripts/doctor.sh bash -n scripts/lint.sh ``` -External interoperability experiments stay outside the default regression baseline. When you need to reproduce current official-tool behavior, run: +Repository-owned black-box compatibility probes stay outside the default regression baseline. Run them when transport or A2A protocol behavior changes: ```bash bash ./scripts/conformance.sh ``` -Treat that output as investigation input. Do not fold it into `doctor.sh` or the default CI quality gate unless the repository explicitly decides to promote a specific experiment into a maintained policy. +The script does not download or bind to an external TCK. It checks the public Agent Card and both shipped transports against invariants maintained by this repository. A third-party TCK may be used independently as investigation input, but is not a repository dependency or certification claim. If you change extension methods, extension metadata, or Agent Card/OpenAPI contract surfaces, also make sure the targeted contract checks stay green: diff --git a/README.md b/README.md index dd8e41b..7b52ff5 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json - Session continuity through `metadata.shared.session.id` - Request-scoped model selection through `metadata.shared.model` - OpenCode-oriented JSON-RPC extensions for session and model/provider queries +- Authenticated Prometheus-compatible process metrics at `GET /metrics` ## A2A Protocol Support @@ -177,7 +178,7 @@ Read before deployment: - [docs/compatibility.md](docs/compatibility.md) Compatibility-sensitive surface and contract-honesty guidance. - [docs/guide.md](docs/guide.md) Usage guide, transport details, streaming behavior, extensions, and examples. - [docs/security-architecture.md](docs/security-architecture.md) Security surface, boundaries, and residual-risk register. -- [docs/conformance.md](docs/conformance.md) External TCK experiment workflow and artifact handling. +- [docs/conformance.md](docs/conformance.md) Repository-owned black-box compatibility probes and artifact handling. - [SECURITY.md](SECURITY.md) Threat model, deployment caveats, and vulnerability disclosure guidance. ## Development diff --git a/docs/architecture.md b/docs/architecture.md index fa5771c..9d8f803 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,6 +97,6 @@ Use the docs by responsibility: - [Maintainer Architecture Guide](maintainer-architecture.md): internal module boundaries, request call chains, and persistence touchpoints - [Extension Specifications](extension-specifications.md): stable extension URI/spec index and disclosure policy - [Security Architecture](security-architecture.md): security surface mapping and residual-risk register -- [Conformance Notes](conformance.md): external TCK experiment workflow +- [Conformance Notes](conformance.md): repository-owned black-box compatibility probes - [Contributing Guide](../CONTRIBUTING.md): contributor workflow and validation - [Security Policy](../SECURITY.md): threat model and disclosure guidance diff --git a/docs/compatibility.md b/docs/compatibility.md index b8e1892..ec96c93 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -4,7 +4,7 @@ This document defines the compatibility promises `opencode-a2a` currently uphold ## Runtime Support -- Python versions: 3.11, 3.12, 3.13 +- Python versions: 3.11, 3.12, 3.13, 3.14 - A2A SDK line: `1.x.y` - Supported A2A protocol line: `1.0` - OpenCode runtime line: `1.18.x` (verified with `1.18.19`) @@ -27,7 +27,7 @@ If runtime support is not implemented, do not publish it as a supported machine- Consumer guidance: -- Treat the v1 core A2A methods (`SendMessage`, `SendStreamingMessage`, `GetTask`, `CancelTask`, `SubscribeToTask`) as the portable baseline. +- Discover the complete SDK-owned v1 core method set from `core.jsonrpc_methods`; do not infer it from a shortened documentation list. - Treat `urn:opencode-a2a:extension:...` entries in this repository as repository-governed extension identifiers, not as a claim that they are part of the A2A core baseline. - Treat `opencode.*` methods and `metadata.opencode.*` fields as provider-private OpenCode control and discovery surfaces layered on top of the portable A2A baseline. - Treat [extension-specifications.md](./extension-specifications.md) as the stable URI/spec index, not as the main usage guide. @@ -40,7 +40,7 @@ When docs or reference material disagree, treat these as normative in this order - machine-readable discovery output such as Agent Card, authenticated extended card, and OpenAPI metadata - repository-owned docs in `README.md`, `docs/`, and `CONTRIBUTING.md` -External TCK runs and local conformance experiments are investigation inputs. They do not override the repository's declared contract by themselves. +The repository-owned black-box compatibility probes protect selected runtime invariants but are not a complete conformance suite. Third-party TCK output is investigation input only and does not override the repository's declared contract by itself. ## Compatibility-Sensitive Surface @@ -60,7 +60,7 @@ Changes to those surfaces should be treated as compatibility-sensitive and shoul Service-level behavior layered on top of those core methods should also be declared explicitly when interoperability depends on it. Current examples: -- `SubscribeToTask` replay-once behavior for terminal updates +- `SubscribeToTask` rejection with `UnsupportedOperationError` for terminal tasks - first-terminal-state-wins task persistence policy - task-scoped `acceptedOutputModes` negotiation persistence across send / stream / get / subscribe - request-body rejection behavior for oversized transport payloads diff --git a/docs/conformance-triage.md b/docs/conformance-triage.md deleted file mode 100644 index 4a1811b..0000000 --- a/docs/conformance-triage.md +++ /dev/null @@ -1,32 +0,0 @@ -# External Conformance Triage - -This document summarizes the current interpretation rules for external TCK runs after the repository's A2A v1 migration. - -## Current Runtime Baseline - -- `opencode-a2a` now targets the `a2a-sdk 1.x.y` line -- the runtime is v1-only -- canonical JSON-RPC core methods are `SendMessage`, `SendStreamingMessage`, `GetTask`, `CancelTask`, and `SubscribeToTask` -- legacy `0.3` aliases and payload shapes are intentionally rejected rather than normalized - -## How To Read TCK Failures - -When a TCK run fails, classify the result before changing the runtime: - -- `Runtime gap` - - the failure reproduces against the current v1-only runtime and contradicts the repository's declared machine-readable contract -- `TCK assumption mismatch` - - the failure depends on method names, payload shapes, or schema expectations that do not match the current A2A v1 SDK/runtime contract -- `Local experiment artifact` - - the failure depends on dummy-backed local behavior, environment heuristics, or unrelated tooling/setup issues - -## Current Guidance - -- Re-run conformance against the current runtime before using any historical triage note. -- Treat Agent Card, authenticated extended card, OpenAPI, and runtime tests as the repository's declared source of truth. -- Do not reopen removed `0.3` compatibility behavior just to satisfy an outdated TCK assumption. -- If a TCK gap is real, document it against the current v1 contract with the exact request/response payloads that failed. - -## Historical Note - -Earlier repository-local triage notes were written before the v1 migration and described a mixed `0.3` / partial `1.0` state. Those notes are no longer normative and were removed to avoid stale guidance. diff --git a/docs/conformance.md b/docs/conformance.md index b252469..338d7ad 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -1,67 +1,50 @@ -# External Conformance Experiments +# Repository-Owned Compatibility Probes -This repository keeps internal regression and external interoperability experiments separate on purpose. +`./scripts/conformance.sh` runs black-box checks maintained and reviewed with this repository. It deliberately does not clone, pin, or execute the official A2A TCK. ## Scope -- `./scripts/doctor.sh` remains the primary internal regression entrypoint. -- `./scripts/conformance.sh` is a local/manual experiment entrypoint for official external tooling. -- External conformance output should be treated as investigation input, not as an automatic merge gate. +The probe verifies a small set of high-value A2A 1.0 invariants through public HTTP boundaries: -## Current Experiment Shape +- Agent Card discovery advertises both HTTP+JSON and JSON-RPC interfaces +- empty `SendMessage` input is rejected before execution +- unsupported push notification configuration uses the protocol-specific error +- subscribing to a terminal task returns `UnsupportedOperationError` on both transports +- `ListTasks` is reachable through both shipped transports -The default `./scripts/conformance.sh` workflow does the following: - -1. Sync the repository environment unless explicitly skipped. -2. Cache or refresh the official `a2aproject/a2a-tck` checkout. -3. Start a local dummy-backed `opencode-a2a` runtime unless `CONFORMANCE_SUT_URL` points to an existing SUT. -4. Run the requested TCK category, defaulting to `mandatory`. -5. Preserve raw logs and machine-readable reports under `run/conformance//`. - -The default local SUT uses the repository test double `DummyChatOpencodeUpstreamClient`. That keeps the experiment reproducible without requiring a live OpenCode upstream. +These checks protect this runtime's declared contract. They are not a complete A2A conformance suite and must not be presented as certification. ## Usage -Run the default mandatory experiment: +Run against the local dummy-backed runtime: ```bash bash ./scripts/conformance.sh ``` -Run a different TCK category: - -```bash -bash ./scripts/conformance.sh capabilities -``` - -Target an already running runtime instead of the local dummy-backed SUT: +Run against an existing deployment: ```bash CONFORMANCE_SUT_URL=http://127.0.0.1:8000 \ -A2A_AUTH_TYPE=bearer \ -A2A_AUTH_TOKEN=dev-token \ -bash ./scripts/conformance.sh mandatory +CONFORMANCE_AUTH_TOKEN=dev-token \ +CONFORMANCE_ALLOW_EXTERNAL=1 \ +bash ./scripts/conformance.sh ``` -## Artifacts +The probe creates a real task and reads task state. Use a dedicated test deployment, never a production target. `CONFORMANCE_ALLOW_EXTERNAL=1` is a required explicit acknowledgement, and `CONFORMANCE_AUTH_TOKEN` is required for an existing deployment. The default `test-token` is used only for the locally launched test SUT. -Each run keeps the following artifacts in the selected output directory: +Use `CONFORMANCE_OUTPUT_DIR` to select the artifact directory and `CONFORMANCE_SKIP_REPO_SYNC=1` only when the locked environment has already been verified. -- `agent-card.json`: fetched public Agent Card -- `health.json`: fetched authenticated health payload when the local SUT is used -- `tck.log`: raw TCK console output -- `pytest-report.json`: pytest-json-report output emitted by the TCK runner -- `failed-tests.json`: compact list of failed/error node IDs for triage -- `metadata.json`: experiment metadata including local repo commit and cached TCK commit - -## Interpretation Guidance +## Artifacts -When a TCK run fails, inspect the raw report before changing the runtime: +Each run writes: -- Some failures may point to real runtime gaps. -- Some failures may come from TCK assumptions that do not match the current `a2a-sdk 1.x.y` contract. -- Some failures may come from local dummy-backed experiment behavior rather than a wire-level runtime defect. +- `agent-card.json`: the discovered public Agent Card +- `report.json`: versioned check results and repository revision +- `probe.log`: human-readable probe output +- `sut.log`: local test-runtime output, when the script launches it +- `repo-health.log`: repository environment checks, unless explicitly skipped -The experiment is useful only if those categories stay separate during triage. +## External Tools -The current first-pass triage is recorded in [`./conformance-triage.md`](./conformance-triage.md). +Maintainers may run third-party TCKs independently to investigate interoperability. Record exact tool revisions and wire payloads when reporting a finding. External output is evidence to triage, not an automatic merge gate, source of runtime truth, or reason to restore obsolete protocol behavior. diff --git a/docs/guide.md b/docs/guide.md index 06b38c4..2e93af6 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -43,6 +43,7 @@ Key variables to understand protocol behavior: - `A2A_ALLOWED_HOSTS`: comma-separated `Host` header allowlist (exact names or `*.example.com` wildcards). When configured, every inbound request must present a matching `Host` header. Binding to a non-loopback address without this allowlist logs a startup warning (DNS rebinding risk). - `A2A_LOG_LEVEL`: runtime log level. Default: `WARNING`. - `A2A_LOG_PAYLOADS` / `A2A_LOG_BODY_LIMIT`: payload logging behavior and truncation. When `A2A_LOG_LEVEL=DEBUG`, upstream OpenCode stream events are also logged with preview truncation controlled by `A2A_LOG_BODY_LIMIT`. +- `A2A_METRICS_ENABLED`: expose process-local Prometheus text metrics at authenticated `GET /metrics`. Default: `true`. - The runtime accepts W3C `traceparent` / `tracestate` headers on inbound requests. When `traceparent` is missing or invalid, the runtime generates a fresh valid value and exposes it on the HTTP response header. - The active `traceparent` / `tracestate` pair is propagated across inbound A2A handling, OpenCode upstream requests, and outbound peer A2A calls triggered through the embedded client or `a2a_call` tool path. - Logs derive a stable `trace_id` from the active `traceparent` so request-scoped log lines can be correlated without introducing high-cardinality metric labels. @@ -341,7 +342,7 @@ Current behavior: - The current SDK-owned core JSON-RPC surface includes `GetExtendedAgentCard` and `tasks/pushNotificationConfig/*`. - The current SDK-owned REST surface also includes `GET /tasks` and the task push notification config routes. - The SDK-owned core JSON-RPC method set follows the pinned `a2a-sdk` release and is locked by repository tests; review that surface deliberately when upgrading the SDK. -- Push notification config routes/methods are currently exposed only because they are part of the SDK-owned core surface. This runtime does not configure a push config store or push sender, so push notification operations remain unsupported. REST routes currently return HTTP `501`, while JSON-RPC methods surface SDK-owned unsupported error envelopes. +- Push notification config routes/methods are currently exposed only because they are part of the SDK-owned core surface. This runtime does not configure a push config store or push sender, so operations return the A2A 1.0 `PushNotificationNotSupportedError` contract: HTTP `400` for REST and JSON-RPC code `-32003`. When `A2A_ENABLE_SESSION_SHELL=false`, `opencode.sessions.shell` is omitted from `all_jsonrpc_methods` and exposed only through `extensions.conditionally_available_methods`. @@ -384,7 +385,7 @@ Current compatibility matrix: | Transport payloads and enums | Supported | Request/response payloads, enums, and schema details follow the current SDK-owned v1 baseline. | | Error model | Supported | JSON-RPC and REST both use the v1 protocol-aware error shapes. | | Pagination and list semantics | Supported | Cursor/list behavior follows the current SDK baseline. | -| Push notification surfaces | Unsupported | SDK-owned task push-notification routes are still exposed, but this runtime does not enable push sender/config-store support. REST routes return HTTP `501`, while JSON-RPC methods remain unsupported via SDK-owned error envelopes. | +| Push notification surfaces | Unsupported | SDK-owned routes remain exposed, but the runtime has no push sender/config store. REST returns HTTP `400`; JSON-RPC returns `-32003` (`PUSH_NOTIFICATION_NOT_SUPPORTED`). | | Signatures and authenticated data | Supported | Security schemes and authenticated extended card discovery follow the shipped SDK schema. | ## Compatibility Profile @@ -657,7 +658,7 @@ Detailed contract discovery for this provider-private surface is intentionally a - Privacy guard: when `A2A_LOG_PAYLOADS=true`, request/response bodies are still suppressed for `method=opencode.sessions.*` - Endpoint discovery: prefer `supportedInterfaces[]` with `protocolBinding=JSONRPC` from Agent Card - The runtime still delegates SDK-owned JSON-RPC methods such as `GetExtendedAgentCard` and `tasks/pushNotificationConfig/*` to the base A2A implementation; they are not OpenCode-specific extensions. -- Push notification config methods remain effectively unsupported in the current runtime because no push config store or push sender is configured; REST routes return HTTP `501`, while JSON-RPC methods stay on SDK-owned unsupported error handling. +- Push notification config methods remain unsupported because no push config store or sender is configured; REST returns HTTP `400` and JSON-RPC returns `-32003` with `PUSH_NOTIFICATION_NOT_SUPPORTED` details. - Notification behavior: for `opencode.sessions.*`, requests without `id` return HTTP `204 No Content` - Result format: - `opencode.sessions.status` => provider-private status summaries in `result.items` @@ -1319,10 +1320,10 @@ If an SSE connection drops, use `GET /tasks/{task_id}:subscribe` to re-subscribe - For running tasks, the service attempts upstream OpenCode `POST /session/{sessionID}/abort` to stop generation. - Upstream interruption is best-effort: if upstream returns 404, network errors, or other HTTP errors, A2A cancellation still completes with `TaskState.TASK_STATE_CANCELED`. - Idempotency contract: repeated `CancelTask` on an already `canceled` task returns the current terminal task state without error. -- Terminal subscribe contract: calling `SubscribeToTask` or `GET /tasks/{task_id}:subscribe` on a terminal task replays one terminal `Task` snapshot and then closes the stream. +- Terminal subscribe contract: calling `SubscribeToTask` or `GET /tasks/{task_id}:subscribe` on a terminal task returns `UnsupportedOperationError`, as required by A2A 1.0. - Terminal persistence contract: once a terminal task snapshot is persisted, this service treats it as immutable. Producers must emit final text and artifact updates before the terminal event, and any final usage or stream metadata must be attached to that terminal event itself. Late terminal-state mutations are rejected by the task-store write policy. -- These two semantics are also declared as machine-readable `service_behaviors` in the compatibility profile and wire contract extensions. -- At `A2A_LOG_LEVEL=DEBUG`, the service emits lightweight metric log records (`logger=opencode_a2a.execution.executor`): +- The cancellation idempotency enhancement is also declared under machine-readable `service_behaviors`. Terminal-task subscription rejection remains a core A2A 1.0 rule and is therefore not republished as a custom enhancement. +- The service records process-local metrics for authenticated Prometheus scraping at `GET /metrics`; `A2A_METRICS_ENABLED=false` disables the endpoint. At `A2A_LOG_LEVEL=DEBUG`, the same updates are emitted as lightweight metric log records (`logger=opencode_a2a.execution.executor`): - `a2a_stream_requests_total` - `a2a_stream_active` (`value=1` when a stream starts, `value=-1` when it closes) - `opencode_stream_retries_total` diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 739edbc..e9b365e 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -42,14 +42,17 @@ All REST routes are served at the root path (no `/v1` prefix). | GET | `/tasks` | List tasks | Credential | | GET/POST/DELETE | `/tasks/{id}/pushNotificationConfigs[/{push_id}]` | Push notification config (exposed-but-unsupported) | Credential | | POST | `/` | JSON-RPC endpoint (core + extensions) | Credential | -| GET | `/health` | Health / runtime profile | Anonymous | +| GET | `/health` | Health / runtime profile | Credential | +| GET | `/metrics` | Process-local Prometheus metrics (when enabled) | Credential | | GET | `/openapi.json` | Sanitized OpenAPI contract | Anonymous | ### JSON-RPC Surface -- Core A2A methods from the SDK dispatcher: `message/send`, `message/stream`, - `tasks/get`, `tasks/cancel`, `tasks/list`, `tasks/subscribe`, - `tasks/pushNotificationConfig/*`. +- Core A2A methods from the SDK dispatcher: `SendMessage`, + `SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`, + `CreateTaskPushNotificationConfig`, `GetTaskPushNotificationConfig`, + `ListTaskPushNotificationConfigs`, `DeleteTaskPushNotificationConfig`, + `SubscribeToTask`, and `GetExtendedAgentCard`. - Provider-private `opencode.*` extensions (authenticated extended card only): - `opencode.sessions.*`: `status`, `list`, `messages.list`, `get`, `children`, `todo`, `diff`, `message.get`, `prompt_async`, `command`, `fork`, `share`, @@ -87,6 +90,7 @@ All REST routes are served at the root path (no `/v1` prefix). | Rate limit | `A2A_RATE_LIMIT_ENABLED` / `_WINDOW_SECONDS` / `_MAX_REQUESTS` | On by default; 60 s window, 120 requests; 429 + `Retry-After` | | Stream budgets | `A2A_STREAM_MAX_BYTES` / `_MAX_DURATION_SECONDS` / `_IDLE_TIMEOUT_SECONDS` | 64 MiB / 3600 s / 120 s; `0` disables | | Payload logging | `A2A_LOG_PAYLOADS` / `A2A_LOG_BODY_LIMIT` | Opt-in; logs treated as sensitive | +| Metrics | `A2A_METRICS_ENABLED` | On by default; authenticated `/metrics`; process-local values | ### Outbound Side Effects @@ -160,7 +164,7 @@ are skipped idempotently. | R-3 | Non-loopback bind without `A2A_ALLOWED_HOSTS` only warns at startup | Accepted | Operator contract: trusted network or reverse proxy validates Host. [guide.md](./guide.md) "Inbound Origin and Host Boundary" | | R-4 | Single-tenant shared-workspace boundary; static credentials only by default | Accepted by design | Threat model and tenant guidance in [SECURITY.md](../SECURITY.md) | | 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 (HTTP 501 / JSON-RPC unsupported) | Accepted | Contract kept explicitly unsupported; capability recovery is intentionally deferred | +| 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" | ## Maintenance Rules diff --git a/pyproject.toml b/pyproject.toml index b483403..43b9454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Framework :: FastAPI", "Topic :: Internet :: WWW/HTTP", ] diff --git a/scripts/README.md b/scripts/README.md index af5b4ea..59280ef 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,7 +12,7 @@ Executable scripts live in this directory. This file is the entry index for the ## Other Scripts - [`doctor.sh`](./doctor.sh): primary local development regression entrypoint with explicit fix/verify/package phases (uv sync + dependency compatibility + lint + mypy + tests + coverage + built-wheel smoke test) -- [`conformance.sh`](./conformance.sh): local/manual external A2A conformance experiment entrypoint; caches the official TCK, can launch a dummy-backed local SUT, and preserves raw artifacts under `run/conformance/` +- [`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 - [`dependency_health.sh`](./dependency_health.sh): development dependency review entrypoint (`sync`/`pip check` + outdated + dev audit), 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 @@ -26,4 +26,4 @@ Executable scripts live in this directory. This file is the entry index for the - `doctor.sh` covers the default local validation baseline, while `dependency_health.sh` remains focused on standalone dependency review and audit flow. - `doctor.sh` stops early when `pre-commit` rewrites files so you can review the changes and rerun `doctor.sh` from the updated worktree. - [`.github/dependabot.yml`](../.github/dependabot.yml) prefers a single weekly grouped Dependabot PR for `uv`, while `dependency_health.sh` remains the explicit review/audit entrypoint. -- External conformance experiments remain intentionally separate from the default regression path. See [`../docs/conformance.md`](../docs/conformance.md). +- Repository-owned compatibility probes remain separate from the default regression path. See [`../docs/conformance.md`](../docs/conformance.md). diff --git a/scripts/conformance.sh b/scripts/conformance.sh old mode 100644 new mode 100755 index b16c3dd..334f259 --- a/scripts/conformance.sh +++ b/scripts/conformance.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run a local-only external A2A conformance experiment without changing default repo regression gates. +# Run repository-owned black-box A2A compatibility probes. set -euo pipefail # shellcheck source=./health_common.sh @@ -8,32 +8,19 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/health_common.sh" usage() { cat <<'EOF' Usage: - bash ./scripts/conformance.sh [category] + bash ./scripts/conformance.sh Purpose: - Run the official A2A TCK as a local/manual experiment. - This script is intentionally separate from doctor.sh and CI quality gates. - -Category: - Defaults to "mandatory". Any category supported by a2aproject/a2a-tck run_tck.py is accepted. + Run repository-owned A2A 1.0 black-box compatibility probes. + This script does not download or depend on an external TCK. Selected environment variables: - CONFORMANCE_OUTPUT_DIR Override artifact directory (default: run/conformance/) - CONFORMANCE_TCK_DIR Override cached TCK checkout path (default: .cache/a2a-tck) - CONFORMANCE_TCK_REPO Override TCK repo URL (default: https://github.com/a2aproject/a2a-tck.git) - CONFORMANCE_TCK_REF Override TCK git ref (default: main) - CONFORMANCE_TRANSPORTS Override requested transports (default: jsonrpc) - CONFORMANCE_TRANSPORT_STRATEGY Override TCK transport strategy (default: agent_preferred) - CONFORMANCE_SUT_URL Use an already running SUT instead of the local dummy-backed runtime - CONFORMANCE_SUT_PORT Override local dummy-backed SUT port (default: 8011) - CONFORMANCE_SKIP_REPO_SYNC=1 Skip uv sync/uv pip check for this repository - CONFORMANCE_SKIP_TCK_SYNC=1 Skip uv sync inside the cached TCK checkout - CONFORMANCE_AUTH_TYPE Default auth type when A2A_AUTH_TYPE is unset (default: bearer) - CONFORMANCE_AUTH_TOKEN Default auth token when A2A_AUTH_TOKEN is unset (default: test-token) - -Advanced authentication: - The script preserves any caller-provided A2A_AUTH_* variables and only sets defaults - for the common bearer-token case used by the local dummy-backed runtime. + CONFORMANCE_OUTPUT_DIR Artifact directory (default: run/conformance/) + CONFORMANCE_SUT_URL Probe an already running runtime instead of a local test SUT + CONFORMANCE_SUT_PORT Local test SUT port (default: 8011) + CONFORMANCE_AUTH_TOKEN Bearer token (default: test-token for the local test SUT) + CONFORMANCE_ALLOW_EXTERNAL=1 Required acknowledgement when CONFORMANCE_SUT_URL is set + CONFORMANCE_SKIP_REPO_SYNC=1 Skip uv sync/uv pip check EOF } @@ -41,28 +28,18 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then usage exit 0 fi - -if [[ "$#" -gt 1 ]]; then - echo "Expected at most one positional argument: category" >&2 - exit 1 +if [[ "$#" -ne 0 ]]; then + echo "This entrypoint accepts no positional arguments." >&2 + exit 2 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" - -category="${1:-${CONFORMANCE_CATEGORY:-mandatory}}" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" output_dir="${CONFORMANCE_OUTPUT_DIR:-${ROOT_DIR}/run/conformance/${timestamp}}" -tck_dir="${CONFORMANCE_TCK_DIR:-${ROOT_DIR}/.cache/a2a-tck}" -tck_repo="${CONFORMANCE_TCK_REPO:-https://github.com/a2aproject/a2a-tck.git}" -tck_ref="${CONFORMANCE_TCK_REF:-main}" -transport_strategy="${CONFORMANCE_TRANSPORT_STRATEGY:-agent_preferred}" -transports="${CONFORMANCE_TRANSPORTS:-jsonrpc}" sut_port="${CONFORMANCE_SUT_PORT:-8011}" -repo_log="${output_dir}/repo-health.log" -tck_sync_log="${output_dir}/tck-sync.log" sut_log="${output_dir}/sut.log" -tck_log="${output_dir}/tck.log" +probe_log="${output_dir}/probe.log" mkdir -p "${output_dir}" @@ -74,175 +51,64 @@ cleanup() { fi exit "${exit_code}" } - trap 'cleanup $?' EXIT cd "${ROOT_DIR}" - if [[ "${CONFORMANCE_SKIP_REPO_SYNC:-0}" != "1" ]]; then - run_shared_repo_health_prerequisites "conformance" >"${repo_log}" 2>&1 -fi - -mkdir -p "$(dirname "${tck_dir}")" -if [[ ! -d "${tck_dir}/.git" ]]; then - git clone --depth 1 "${tck_repo}" "${tck_dir}" >"${output_dir}/tck-clone.log" 2>&1 -fi - -git -C "${tck_dir}" fetch --depth 1 origin "${tck_ref}" >"${output_dir}/tck-fetch.log" 2>&1 -git -C "${tck_dir}" checkout --quiet FETCH_HEAD - -if [[ "${CONFORMANCE_SKIP_TCK_SYNC:-0}" != "1" ]]; then - ( - cd "${tck_dir}" - uv sync - ) >"${tck_sync_log}" 2>&1 -fi - -if [[ -z "${A2A_AUTH_TYPE:-}" ]]; then - export A2A_AUTH_TYPE="${CONFORMANCE_AUTH_TYPE:-bearer}" -fi -if [[ -z "${A2A_AUTH_TOKEN:-}" && "${A2A_AUTH_TYPE}" == "bearer" ]]; then - export A2A_AUTH_TOKEN="${CONFORMANCE_AUTH_TOKEN:-test-token}" + run_shared_repo_health_prerequisites "conformance" >"${output_dir}/repo-health.log" 2>&1 fi sut_url="${CONFORMANCE_SUT_URL:-}" +auth_token="${CONFORMANCE_AUTH_TOKEN:-}" if [[ -z "${sut_url}" ]]; then sut_url="http://127.0.0.1:${sut_port}" - export CONFORMANCE_SUT_PORT="${sut_port}" - export CONFORMANCE_SUT_URL="${sut_url}" - uv run python - <<'PY' >"${sut_log}" 2>&1 & -import uvicorn -import opencode_a2a.server.application as app_module - -from tests.support.helpers import DummyChatOpencodeUpstreamClient, make_settings - -app_module.OpencodeUpstreamClient = DummyChatOpencodeUpstreamClient -settings = make_settings( - a2a_host="127.0.0.1", - a2a_port=int(__import__("os").environ["CONFORMANCE_SUT_PORT"]), - a2a_public_url=__import__("os").environ["CONFORMANCE_SUT_URL"], - a2a_bearer_token=__import__("os").environ.get("A2A_AUTH_TOKEN", "test-token"), -) -app = app_module.create_app(settings) -uvicorn.run(app, host="127.0.0.1", port=settings.a2a_port, log_level="warning") -PY + auth_token="${auth_token:-test-token}" + if ! uv run python -c \ + 'import socket, sys; server = socket.create_server(("127.0.0.1", int(sys.argv[1]))); server.close()' \ + "${sut_port}"; then + echo "Local conformance port ${sut_port} is unavailable; choose CONFORMANCE_SUT_PORT." >&2 + exit 1 + fi + CONFORMANCE_SUT_PORT="${sut_port}" \ + CONFORMANCE_SUT_URL="${sut_url}" \ + CONFORMANCE_AUTH_TOKEN="${auth_token}" \ + uv run python -m scripts.conformance_sut >"${sut_log}" 2>&1 & sut_pid="$!" + sut_ready=0 for _ in $(seq 1 50); do - if curl -fsS "${sut_url}/.well-known/agent-card.json" >"${output_dir}/agent-card.json"; then - if curl -fsS -H "Authorization: Bearer ${A2A_AUTH_TOKEN:-test-token}" "${sut_url}/health" \ - >"${output_dir}/health.json"; then - break - fi + if curl --silent --fail --output /dev/null \ + "${sut_url}/.well-known/agent-card.json" 2>/dev/null; then + sut_ready=1 + break + fi + if ! kill -0 "${sut_pid}" >/dev/null 2>&1; then + echo "Local conformance SUT exited before becoming ready." >&2 + cat "${sut_log}" >&2 || true + exit 1 fi sleep 0.2 done - - if [[ ! -f "${output_dir}/agent-card.json" || ! -f "${output_dir}/health.json" ]]; then - echo "SUT did not become ready at ${sut_url}" >&2 + if [[ "${sut_ready}" != "1" ]]; then + echo "Local conformance SUT did not become ready at ${sut_url}." >&2 cat "${sut_log}" >&2 || true exit 1 fi -else - curl -fsS "${sut_url}/.well-known/agent-card.json" >"${output_dir}/agent-card.json" +elif [[ "${CONFORMANCE_ALLOW_EXTERNAL:-0}" != "1" ]]; then + echo "Set CONFORMANCE_ALLOW_EXTERNAL=1 to acknowledge external SUT side effects." >&2 + exit 2 +elif [[ -z "${auth_token}" ]]; then + echo "CONFORMANCE_AUTH_TOKEN is required with CONFORMANCE_SUT_URL." >&2 + exit 2 fi -json_report_name="pytest-${category}.json" - set +e -( - cd "${tck_dir}" - CONFORMANCE_CATEGORY="${category}" \ - CONFORMANCE_SUT_URL="${sut_url}" \ - CONFORMANCE_JSON_REPORT_NAME="${json_report_name}" \ - CONFORMANCE_TRANSPORT_STRATEGY="${transport_strategy}" \ - CONFORMANCE_TRANSPORTS="${transports}" \ - uv run python - <<'PY' -from __future__ import annotations - -import os -import run_tck - -raise SystemExit( - run_tck.run_test_category( - category=os.environ["CONFORMANCE_CATEGORY"], - sut_url=os.environ["CONFORMANCE_SUT_URL"], - verbose=False, - verbose_log=True, - generate_report=False, - json_report=os.environ["CONFORMANCE_JSON_REPORT_NAME"], - transport_strategy=os.environ["CONFORMANCE_TRANSPORT_STRATEGY"], - enable_equivalence_testing=None, - transports=os.environ["CONFORMANCE_TRANSPORTS"], - ) -) -PY -) 2>&1 | tee "${tck_log}" -tck_exit="${PIPESTATUS[0]}" +CONFORMANCE_AUTH_TOKEN="${auth_token}" \ + uv run python -m scripts.conformance_probe \ + --base-url "${sut_url}" \ + --output-dir "${output_dir}" 2>&1 | tee "${probe_log}" +probe_exit="${PIPESTATUS[0]}" set -e -report_path="${tck_dir}/reports/${json_report_name}" -if [[ -f "${report_path}" ]]; then - cp "${report_path}" "${output_dir}/pytest-report.json" -fi - -CONFORMANCE_CATEGORY="${category}" \ -CONFORMANCE_OUTPUT_DIR="${output_dir}" \ -CONFORMANCE_SUT_URL="${sut_url}" \ -CONFORMANCE_TCK_DIR="${tck_dir}" \ -CONFORMANCE_TCK_REF="${tck_ref}" \ -CONFORMANCE_TRANSPORTS="${transports}" \ -CONFORMANCE_TRANSPORT_STRATEGY="${transport_strategy}" \ -uv run python - <<'PY' -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - -output_dir = Path(os.environ["CONFORMANCE_OUTPUT_DIR"]) -report_path = output_dir / "pytest-report.json" - -metadata = { - "category": os.environ["CONFORMANCE_CATEGORY"], - "sut_url": os.environ["CONFORMANCE_SUT_URL"], - "tck_dir": os.environ["CONFORMANCE_TCK_DIR"], - "tck_ref": os.environ["CONFORMANCE_TCK_REF"], - "transports": os.environ["CONFORMANCE_TRANSPORTS"], - "transport_strategy": os.environ["CONFORMANCE_TRANSPORT_STRATEGY"], - "repo_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), - "tck_commit": subprocess.check_output( - ["git", "-C", os.environ["CONFORMANCE_TCK_DIR"], "rev-parse", "HEAD"], - text=True, - ).strip(), -} - -(output_dir / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n") - -if report_path.exists(): - report = json.loads(report_path.read_text()) - failures = [] - for test in report.get("tests", []): - outcome = test.get("outcome") - if outcome in {"failed", "error"}: - failures.append( - { - "nodeid": test.get("nodeid"), - "outcome": outcome, - "keywords": sorted(test.get("keywords", [])), - } - ) - (output_dir / "failed-tests.json").write_text(json.dumps(failures, indent=2) + "\n") -PY - echo "Conformance artifacts: ${output_dir}" -echo "TCK log: ${tck_log}" -if [[ -f "${output_dir}/pytest-report.json" ]]; then - echo "Pytest JSON report: ${output_dir}/pytest-report.json" -fi -if [[ -f "${output_dir}/failed-tests.json" ]]; then - echo "Failed tests index: ${output_dir}/failed-tests.json" -fi - -exit "${tck_exit}" +exit "${probe_exit}" diff --git a/scripts/conformance_probe.py b/scripts/conformance_probe.py new file mode 100644 index 0000000..d091207 --- /dev/null +++ b/scripts/conformance_probe.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import httpx + + +def _jsonrpc(method: str, params: dict[str, Any], request_id: int) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + + +def _valid_message(message_id: str) -> dict[str, Any]: + return { + "message": { + "messageId": message_id, + "role": "ROLE_USER", + "parts": [{"text": "conformance probe"}], + } + } + + +def _stream_payload(response: httpx.Response) -> dict[str, Any]: + if response.headers.get("content-type", "").startswith("application/json"): + return dict(response.json()) + for line in response.text.splitlines(): + if line.startswith("data:"): + return dict(json.loads(line.removeprefix("data:").strip())) + raise AssertionError( + f"stream response did not contain JSON data (status={response.status_code})" + ) + + +def _error_reason(payload: dict[str, Any]) -> str | None: + details = payload.get("error", {}).get("details", []) + if not details: + details = payload.get("error", {}).get("data", []) + for detail in details: + if isinstance(detail, dict) and isinstance(detail.get("reason"), str): + return detail["reason"] + return None + + +def _assert_equal(actual: Any, expected: Any, label: str) -> None: + if actual != expected: + raise AssertionError(f"{label}: expected {expected!r}, got {actual!r}") + + +def run_probes(base_url: str, bearer_token: str) -> tuple[list[dict[str, Any]], dict[str, Any]]: + results: list[dict[str, Any]] = [] + headers = {"Authorization": f"Bearer {bearer_token}"} + + def check(name: str, probe: Callable[[], None]) -> None: + try: + probe() + except Exception as exc: # noqa: BLE001 - each probe must be reported independently + results.append({"name": name, "status": "failed", "detail": str(exc)}) + else: + results.append({"name": name, "status": "passed"}) + + with httpx.Client(base_url=base_url, headers=headers, timeout=20.0) as client: + card_response = client.get("/.well-known/agent-card.json", headers={}) + card_response.raise_for_status() + card = dict(card_response.json()) + + def agent_card() -> None: + interfaces = card.get("supportedInterfaces", []) + bindings = { + item.get("protocolBinding") for item in interfaces if isinstance(item, dict) + } + if not {"JSONRPC", "HTTP+JSON"}.issubset(bindings): + raise AssertionError(f"missing advertised transports: {sorted(bindings)}") + + check("agent-card-transports", agent_card) + + def jsonrpc_empty_message() -> None: + response = client.post("/", json=_jsonrpc("SendMessage", {}, 1)) + _assert_equal(response.status_code, 200, "HTTP status") + _assert_equal(response.json().get("error", {}).get("code"), -32602, "error code") + + check("jsonrpc-empty-message-invalid-params", jsonrpc_empty_message) + + def rest_empty_message() -> None: + response = client.post("/message:send", json={}) + _assert_equal(response.status_code, 400, "HTTP status") + _assert_equal( + response.json().get("error", {}).get("status"), + "INVALID_ARGUMENT", + "status", + ) + + check("rest-empty-message-invalid-argument", rest_empty_message) + + def jsonrpc_push_unsupported() -> None: + response = client.post( + "/", + json=_jsonrpc("GetTaskPushNotificationConfig", {"id": "probe-task"}, 2), + ) + payload = response.json() + _assert_equal(payload.get("error", {}).get("code"), -32003, "error code") + _assert_equal(_error_reason(payload), "PUSH_NOTIFICATION_NOT_SUPPORTED", "reason") + + check("jsonrpc-push-notification-not-supported", jsonrpc_push_unsupported) + + def rest_push_unsupported() -> None: + response = client.get("/tasks/probe-task/pushNotificationConfigs/probe-config") + payload = response.json() + _assert_equal(response.status_code, 400, "HTTP status") + _assert_equal(_error_reason(payload), "PUSH_NOTIFICATION_NOT_SUPPORTED", "reason") + + check("rest-push-notification-not-supported", rest_push_unsupported) + + task_id: str | None = None + response = client.post("/", json=_jsonrpc("SendMessage", _valid_message("probe-1"), 3)) + if response.status_code == 200: + result = response.json().get("result", {}) + task = result.get("task", result) if isinstance(result, dict) else {} + if isinstance(task, dict) and isinstance(task.get("id"), str): + task_id = task["id"] + + def jsonrpc_terminal_subscribe() -> None: + if task_id is None: + raise AssertionError("valid SendMessage did not return a terminal task") + response = client.post( + "/", + json=_jsonrpc("SubscribeToTask", {"id": task_id}, 4), + headers={**headers, "Accept": "text/event-stream"}, + ) + payload = _stream_payload(response) + _assert_equal(payload.get("error", {}).get("code"), -32004, "error code") + + check("jsonrpc-terminal-task-subscribe-unsupported", jsonrpc_terminal_subscribe) + + def rest_terminal_subscribe() -> None: + if task_id is None: + raise AssertionError("valid SendMessage did not return a terminal task") + response = client.get( + f"/tasks/{task_id}:subscribe", + headers={**headers, "Accept": "text/event-stream"}, + ) + payload = _stream_payload(response) + _assert_equal(response.status_code, 400, "HTTP status") + _assert_equal(_error_reason(payload), "UNSUPPORTED_OPERATION", "reason") + + check("rest-terminal-task-subscribe-unsupported", rest_terminal_subscribe) + + def list_tasks_transports() -> None: + rpc = client.post("/", json=_jsonrpc("ListTasks", {}, 5)) + _assert_equal(rpc.status_code, 200, "JSON-RPC HTTP status") + if "result" not in rpc.json(): + raise AssertionError("JSON-RPC ListTasks did not return a result") + rest = client.get("/tasks") + _assert_equal(rest.status_code, 200, "REST HTTP status") + if "tasks" not in rest.json(): + raise AssertionError("REST ListTasks did not return tasks") + + check("list-tasks-both-transports", list_tasks_transports) + + return results, card + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run repository-owned A2A compatibility probes") + parser.add_argument("--base-url", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + + token = os.environ.get("CONFORMANCE_AUTH_TOKEN") + if not token: + parser.error("CONFORMANCE_AUTH_TOKEN is required") + + args.output_dir.mkdir(parents=True, exist_ok=True) + results, card = run_probes(args.base_url.rstrip("/"), token) + failures = [result for result in results if result["status"] == "failed"] + report = { + "schema_version": 1, + "scope": "repository-owned-a2a-1.0-compatibility-probes", + "sut_url": args.base_url, + "repo_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "summary": { + "passed": len(results) - len(failures), + "failed": len(failures), + "total": len(results), + }, + "checks": results, + } + (args.output_dir / "agent-card.json").write_text( + json.dumps(card, indent=2, sort_keys=True) + "\n" + ) + (args.output_dir / "report.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n" + ) + + for result in results: + marker = "PASS" if result["status"] == "passed" else "FAIL" + detail = f": {result['detail']}" if "detail" in result else "" + print(f"[{marker}] {result['name']}{detail}") + print(f"Summary: {report['summary']}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/conformance_sut.py b/scripts/conformance_sut.py new file mode 100644 index 0000000..425d162 --- /dev/null +++ b/scripts/conformance_sut.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import os + +import uvicorn +from tests.support.helpers import DummyChatOpencodeUpstreamClient +from tests.support.settings import make_settings + +import opencode_a2a.server.application as app_module + + +def main() -> None: + port = int(os.environ["CONFORMANCE_SUT_PORT"]) + app_module.OpencodeUpstreamClient = DummyChatOpencodeUpstreamClient + app = app_module.create_app( + make_settings( + test_bearer_token=os.environ["CONFORMANCE_AUTH_TOKEN"], + a2a_host="127.0.0.1", + a2a_port=port, + a2a_public_url=os.environ["CONFORMANCE_SUT_URL"], + a2a_rate_limit_max_requests=10_000, + ) + ) + uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/src/opencode_a2a/config.py b/src/opencode_a2a/config.py index fa8f68c..84f1775 100644 --- a/src/opencode_a2a/config.py +++ b/src/opencode_a2a/config.py @@ -183,6 +183,7 @@ class Settings(BaseSettings): a2a_log_level: str = Field(default="WARNING", alias="A2A_LOG_LEVEL") a2a_log_payloads: bool = Field(default=False, alias="A2A_LOG_PAYLOADS") a2a_log_body_limit: int = Field(default=0, alias="A2A_LOG_BODY_LIMIT") + a2a_metrics_enabled: bool = Field(default=True, alias="A2A_METRICS_ENABLED") a2a_http_gzip_minimum_size: int = Field( default=8_192, ge=0, diff --git a/src/opencode_a2a/contracts/extensions/compatibility.py b/src/opencode_a2a/contracts/extensions/compatibility.py index c959ccd..328b751 100644 --- a/src/opencode_a2a/contracts/extensions/compatibility.py +++ b/src/opencode_a2a/contracts/extensions/compatibility.py @@ -408,14 +408,5 @@ def build_service_behavior_contract_params() -> dict[str, Any]: } }, }, - "SubscribeToTask": { - "baseline": "core", - "retention": "stable", - "terminal_state_behavior": { - "behavior": identifiers.TERMINAL_RESUBSCRIBE_BEHAVIOR, - "delivery": "single_task_snapshot", - "closes_stream": True, - }, - }, }, } diff --git a/src/opencode_a2a/contracts/extensions/identifiers.py b/src/opencode_a2a/contracts/extensions/identifiers.py index 26f81fa..f1625e4 100644 --- a/src/opencode_a2a/contracts/extensions/identifiers.py +++ b/src/opencode_a2a/contracts/extensions/identifiers.py @@ -45,4 +45,3 @@ ) SERVICE_BEHAVIOR_CLASSIFICATION = "service-level-semantic-enhancement" CANCEL_IDEMPOTENCY_BEHAVIOR = "return_current_terminal_task" -TERMINAL_RESUBSCRIBE_BEHAVIOR = "replay_terminal_task_once_then_close" diff --git a/src/opencode_a2a/execution/metrics.py b/src/opencode_a2a/execution/metrics.py index 095bc71..f965ea4 100644 --- a/src/opencode_a2a/execution/metrics.py +++ b/src/opencode_a2a/execution/metrics.py @@ -1,8 +1,67 @@ from __future__ import annotations import logging +import re +from threading import Lock +from typing import Any logger = logging.getLogger("opencode_a2a.execution.executor") +_METRIC_NAME_PATTERN = re.compile(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$") +_LABEL_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") +_registry_lock = Lock() +_registry: dict[tuple[str, tuple[tuple[str, str], ...]], float] = {} + + +def _normalized_labels(labels: dict[str, Any]) -> tuple[tuple[str, str], ...]: + normalized: list[tuple[str, str]] = [] + for key, value in sorted(labels.items()): + if not _LABEL_NAME_PATTERN.fullmatch(key): + raise ValueError(f"Invalid metric label name: {key!r}") + normalized.append((key, str(value).lower() if isinstance(value, bool) else str(value))) + return tuple(normalized) + + +def _record_metric(name: str, value: float, labels: tuple[tuple[str, str], ...]) -> None: + if not _METRIC_NAME_PATTERN.fullmatch(name): + raise ValueError(f"Invalid metric name: {name!r}") + key = (name, labels) + with _registry_lock: + if name.endswith("_total") or name.endswith("_active"): + _registry[key] = _registry.get(key, 0.0) + value + else: + _registry[key] = value + + +def _escape_label_value(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + +def render_prometheus_metrics() -> str: + """Render the process-local metric registry in Prometheus text format.""" + with _registry_lock: + samples = sorted(_registry.items()) + + lines: list[str] = [] + declared: set[str] = set() + for (name, labels), value in samples: + if name not in declared: + metric_type = "counter" if name.endswith("_total") else "gauge" + lines.extend( + (f"# HELP {name} opencode-a2a runtime metric.", f"# TYPE {name} {metric_type}") + ) + declared.add(name) + label_text = "" + if labels: + escaped = [f'{key}="{_escape_label_value(value)}"' for key, value in labels] + label_text = "{" + ",".join(escaped) + "}" + lines.append(f"{name}{label_text} {value:g}") + return "\n".join(lines) + ("\n" if lines else "") + + +def reset_metrics() -> None: + """Clear process-local metrics; intended for isolated tests.""" + with _registry_lock: + _registry.clear() def emit_metric( @@ -10,6 +69,8 @@ def emit_metric( value: float = 1.0, **labels: str | int | float | bool, ) -> None: + normalized_labels = _normalized_labels(labels) + _record_metric(name, value, normalized_labels) if labels: labels_text = ",".join( f"{key}={str(label).lower() if isinstance(label, bool) else label}" diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index ee11343..466c5aa 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -14,6 +14,7 @@ JSON_RPC_ERROR_CODE_MAP, A2AError, InternalError, + PushNotificationNotSupportedError, UnsupportedOperationError, ) from fastapi import FastAPI @@ -29,6 +30,7 @@ ) from ..opencode_upstream_client import OpencodeUpstreamClient from ..redact import redact_absolute_paths +from ..server.request_parsing import validate_send_message_request from ..server.runtime_limits import apply_stream_budget from .dispatch import ( ExtensionHandlerContext, @@ -331,7 +333,7 @@ async def _handle_core_request( if canonical_method in _PUSH_NOTIFICATION_METHODS: return self._generate_protocol_error_response( base_request.id, - UnsupportedOperationError(), + PushNotificationNotSupportedError(), ) if canonical_method == "GetExtendedAgentCard": if base_request.id is None: @@ -372,6 +374,8 @@ async def _handle_core_request( try: params = body.get("params", {}) specific_request = ParseDict(params, model_class()) + if canonical_method in {"SendMessage", "SendStreamingMessage"}: + validate_send_message_request(specific_request) except Exception as exc: return self._generate_protocol_error_response( base_request.id, diff --git a/src/opencode_a2a/profile/runtime.py b/src/opencode_a2a/profile/runtime.py index cfd94a5..49fef8f 100644 --- a/src/opencode_a2a/profile/runtime.py +++ b/src/opencode_a2a/profile/runtime.py @@ -92,11 +92,13 @@ def as_dict(self) -> dict[str, Any]: class ServiceFeaturesProfile: streaming: dict[str, Any] health_endpoint: dict[str, Any] + metrics_endpoint: dict[str, Any] def as_dict(self) -> dict[str, Any]: return { "streaming": dict(self.streaming), "health_endpoint": dict(self.health_endpoint), + "metrics_endpoint": dict(self.metrics_endpoint), } @@ -291,6 +293,13 @@ def build_runtime_profile(settings: Settings) -> RuntimeProfile: service_features=ServiceFeaturesProfile( streaming={"enabled": True, "availability": "always"}, health_endpoint={"enabled": True, "availability": "always"}, + metrics_endpoint={ + "enabled": settings.a2a_metrics_enabled, + "availability": "enabled" if settings.a2a_metrics_enabled else "disabled", + "path": "/metrics", + "authentication": "required", + "toggle": "A2A_METRICS_ENABLED", + }, ), runtime_context=RuntimeContext( project=settings.a2a_project, diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index a49a0b0..96510e9 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -26,6 +26,7 @@ InvalidRequestError, Message, Part, + PushNotificationNotSupportedError, Role, SendMessageRequest, SendMessageResponse, @@ -47,7 +48,7 @@ ) from a2a.utils.task import apply_history_length, validate_history_length from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, PlainTextResponse from google.protobuf.json_format import MessageToDict, ParseDict, ParseError from google.protobuf.message import Message as ProtoMessage from pydantic_settings import BaseSettings @@ -67,6 +68,7 @@ build_capability_snapshot, ) from ..execution.executor import OpencodeAgentExecutor +from ..execution.metrics import render_prometheus_metrics from ..extension_negotiation import ( ExtensionRequirement, filter_negotiated_extensions_from_payload, @@ -105,6 +107,7 @@ ) from .request_parsing import ( _parse_json_body, + validate_send_message_request, ) from .rest_tasks import build_list_tasks_route from .runtime_limits import StreamBudgetExceeded, apply_stream_budget @@ -222,7 +225,12 @@ def _parse_rest_send_message_request(body: bytes): "such as text, raw, url, or data." ) ) - return ParseDict(payload, SendMessageRequest()) + request = ParseDict(payload, SendMessageRequest()) + try: + validate_send_message_request(request) + except ValueError as exc: + raise InvalidRequestError(message=str(exc)) from exc + return request if TYPE_CHECKING: @@ -593,10 +601,10 @@ async def on_subscribe_to_task( if not task: raise TaskNotFoundError() - # Subscribe contract: terminal tasks replay once and then close stream. if task.status.state in TERMINAL_TASK_STATES: - yield self._apply_task_output_negotiation(task, context) - return + raise UnsupportedOperationError( + message="Cannot subscribe to a task in a terminal state" + ) yield self._apply_task_output_negotiation(task, context) @@ -1051,15 +1059,9 @@ async def _handler(context): app.add_api_route("/tasks/{id}", rest_dispatcher.on_get_task, methods=["GET"]) async def push_notifications_unsupported_route(request: Request) -> JSONResponse: - del request - return JSONResponse( - build_http_error_body( - status_code=501, - status="UNIMPLEMENTED", - message=PUSH_NOTIFICATIONS_UNSUPPORTED_MESSAGE, - reason="PUSH_NOTIFICATIONS_UNSUPPORTED", - ), - status_code=501, + return _rest_error_response( + request=request, + error=PushNotificationNotSupportedError(message=PUSH_NOTIFICATIONS_UNSUPPORTED_MESSAGE), ) app.add_api_route( @@ -1117,6 +1119,15 @@ async def health_check(): protocol_version=A2A_PROTOCOL_VERSION, ) + if settings.a2a_metrics_enabled: + + @app.get("/metrics", response_class=PlainTextResponse) + async def metrics_check() -> PlainTextResponse: + return PlainTextResponse( + render_prometheus_metrics(), + media_type="text/plain; version=0.0.4", + ) + return app diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index a2979eb..4e30560 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -4,6 +4,7 @@ from fastapi import FastAPI +from ..a2a_protocol import CORE_JSONRPC_METHODS from ..config import Settings from ..contracts.extensions import ( INTERRUPT_CALLBACK_EXTENSION_URI, @@ -26,9 +27,10 @@ def _build_jsonrpc_extension_openapi_description() -> str: interrupt_methods = ", ".join(sorted(INTERRUPT_CALLBACK_METHODS.values())) + core_methods = ", ".join(CORE_JSONRPC_METHODS) return ( "A2A JSON-RPC entrypoint. Supports core A2A methods " - "(SendMessage, SendStreamingMessage, GetTask, CancelTask, SubscribeToTask) " + f"({core_methods}) " "plus shared session binding, shared model-selection metadata, shared stream " "hints, and shared interrupt callback methods.\n\n" "Anonymous discovery intentionally exposes only the minimal shared extension " diff --git a/src/opencode_a2a/server/request_parsing.py b/src/opencode_a2a/server/request_parsing.py index 5ac927e..a0cbe23 100644 --- a/src/opencode_a2a/server/request_parsing.py +++ b/src/opencode_a2a/server/request_parsing.py @@ -3,6 +3,7 @@ import json import logging +from a2a.types import SendMessageRequest from fastapi.responses import JSONResponse from ..contracts.extensions import ( @@ -16,6 +17,23 @@ logger = logging.getLogger(__name__) +def validate_send_message_request(request: SendMessageRequest) -> None: + """Validate required A2A Message fields omitted by proto3 parsing.""" + if not request.HasField("message"): + raise ValueError("message is required") + + message = request.message + if not message.message_id.strip(): + raise ValueError("message.messageId is required") + if message.role == 0: + raise ValueError("message.role is required") + if not message.parts: + raise ValueError("message.parts must contain at least one part") + for index, part in enumerate(message.parts): + if part.WhichOneof("content") is None: + raise ValueError(f"message.parts[{index}] must contain content") + + def _parse_json_body(body_bytes: bytes) -> dict | None: try: payload = json.loads(body_bytes.decode("utf-8", errors="replace")) diff --git a/tests/config/test_settings.py b/tests/config/test_settings.py index c3a1d4d..b9ef5cd 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -35,6 +35,7 @@ def test_settings_use_bounded_admission_defaults() -> None: settings = make_settings() assert settings.a2a_rate_limit_enabled is True + assert settings.a2a_metrics_enabled is True assert settings.a2a_rate_limit_window_seconds == 60.0 assert settings.a2a_rate_limit_max_requests == 120 assert settings.a2a_stream_max_bytes == 64 * 1024 * 1024 @@ -63,6 +64,7 @@ def test_settings_valid(): "A2A_HTTP_GZIP_MINIMUM_SIZE": "2048", "A2A_MAX_REQUEST_BODY_BYTES": "2048", "A2A_RATE_LIMIT_ENABLED": "false", + "A2A_METRICS_ENABLED": "false", "A2A_RATE_LIMIT_WINDOW_SECONDS": "30", "A2A_RATE_LIMIT_MAX_REQUESTS": "45", "A2A_STREAM_MAX_BYTES": "1048576", @@ -98,6 +100,7 @@ def test_settings_valid(): assert settings.a2a_http_gzip_minimum_size == 2048 assert settings.a2a_max_request_body_bytes == 2048 assert settings.a2a_rate_limit_enabled is False + assert settings.a2a_metrics_enabled is False assert settings.a2a_rate_limit_window_seconds == 30.0 assert settings.a2a_rate_limit_max_requests == 45 assert settings.a2a_stream_max_bytes == 1_048_576 diff --git a/tests/execution/test_cancellation.py b/tests/execution/test_cancellation.py index 5512390..9105f99 100644 --- a/tests/execution/test_cancellation.py +++ b/tests/execution/test_cancellation.py @@ -4,7 +4,7 @@ import httpx import pytest -from a2a.server.events.event_queue import EventQueue +from a2a.server.events import EventQueue, InMemoryQueueManager from a2a.types import TaskState, TaskStatusUpdateEvent from opencode_a2a.execution.executor import OpencodeAgentExecutor @@ -113,7 +113,7 @@ async def test_cancel_does_not_block_with_real_event_queue() -> None: context_id=None, call_context_enabled=False, ) - queue = EventQueue() + queue = await InMemoryQueueManager().create_or_tap("test-cancel") await asyncio.wait_for(executor.cancel(context, queue), timeout=0.5) diff --git a/tests/execution/test_metrics.py b/tests/execution/test_metrics.py index b66ad22..a592a84 100644 --- a/tests/execution/test_metrics.py +++ b/tests/execution/test_metrics.py @@ -19,6 +19,7 @@ ) from opencode_a2a.execution.executor import OpencodeAgentExecutor +from opencode_a2a.execution.metrics import emit_metric, render_prometheus_metrics, reset_metrics from opencode_a2a.execution.stream_state import _StreamOutputState from opencode_a2a.server.application import OpencodeRequestHandler from tests.support.helpers import DummyEventQueue @@ -39,6 +40,21 @@ def _agent_card() -> AgentCard: return AgentCard(name="opencode-a2a", capabilities=AgentCapabilities(streaming=True)) +def test_metrics_registry_renders_counters_gauges_and_labels() -> None: + reset_metrics() + emit_metric("probe_requests_total", transport="jsonrpc") + emit_metric("probe_requests_total", transport="jsonrpc") + emit_metric("probe_active", 1) + emit_metric("probe_active", -1) + + rendered = render_prometheus_metrics() + + assert "# TYPE probe_requests_total counter" in rendered + assert 'probe_requests_total{transport="jsonrpc"} 2' in rendered + assert "# TYPE probe_active gauge" in rendered + assert "probe_active 0" in rendered + + @pytest.mark.asyncio async def test_stream_request_metrics_track_total_and_active(caplog) -> None: class _FakeAggregator: diff --git a/tests/jsonrpc/test_application_dispatch.py b/tests/jsonrpc/test_application_dispatch.py index 3d2c99c..e57ba92 100644 --- a/tests/jsonrpc/test_application_dispatch.py +++ b/tests/jsonrpc/test_application_dispatch.py @@ -273,6 +273,27 @@ async def test_handle_core_request_invalid_params_and_handler_errors( ) +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["SendMessage", "SendStreamingMessage"]) +async def test_handle_core_request_rejects_missing_message( + monkeypatch: pytest.MonkeyPatch, + method: str, +) -> None: + dispatcher = _build_dispatcher(monkeypatch) + base_request = JSONRPCRequest.model_validate( + {"jsonrpc": "2.0", "id": 140, "method": method, "params": {}} + ) + + response = await dispatcher._handle_core_request( + MagicMock(), + {"params": {}}, + base_request, + ) + + assert response.status_code == 200 + assert b'"code":-32602' in response.body + + @pytest.mark.asyncio async def test_handle_core_request_streaming_and_non_streaming_notifications( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index b99d5c9..9599c53 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -81,8 +81,8 @@ async def test_sendmessage_uses_canonical_v1_method_dispatch() -> None: body = response.json() assert body["jsonrpc"] == "2.0" assert body["id"] == 123 - assert body.get("error") is None - assert body["result"]["task"]["status"]["state"] == "TASK_STATE_FAILED" + assert body["error"]["code"] == -32602 + assert body["error"]["message"] == "Invalid parameters" @pytest.mark.asyncio diff --git a/tests/profile/test_profile_runtime.py b/tests/profile/test_profile_runtime.py index 5ed146d..783e483 100644 --- a/tests/profile/test_profile_runtime.py +++ b/tests/profile/test_profile_runtime.py @@ -85,6 +85,13 @@ def test_profile_runtime_splits_deployment_runtime_features_and_health_payload() "enabled": True, "availability": "always", }, + "metrics_endpoint": { + "enabled": True, + "availability": "enabled", + "path": "/metrics", + "authentication": "required", + "toggle": "A2A_METRICS_ENABLED", + }, }, }, "runtime_context": { diff --git a/tests/scripts/test_script_health_contract.py b/tests/scripts/test_script_health_contract.py index 38ba170..a1dfa62 100644 --- a/tests/scripts/test_script_health_contract.py +++ b/tests/scripts/test_script_health_contract.py @@ -1,3 +1,7 @@ +import json +import os +import socket +import subprocess from pathlib import Path DOCTOR_TEXT = Path("scripts/doctor.sh").read_text() @@ -55,7 +59,7 @@ def test_dependency_health_keeps_dependency_review_scope() -> None: def test_scripts_index_documents_split_health_entrypoints() -> None: assert "local development regression entrypoint" in SCRIPTS_INDEX_TEXT - assert "external A2A conformance experiment entrypoint" in SCRIPTS_INDEX_TEXT + assert "repository-owned A2A 1.0 black-box compatibility probes" in SCRIPTS_INDEX_TEXT assert "dependency review entrypoint" in SCRIPTS_INDEX_TEXT assert "thin forwarding wrappers" in SCRIPTS_INDEX_TEXT assert "health_common.sh" in SCRIPTS_INDEX_TEXT @@ -72,12 +76,39 @@ def test_dependabot_configuration_prefers_a_single_grouped_uv_pr() -> None: assert "uv-all-updates" in DEPENDABOT_TEXT -def test_conformance_script_keeps_external_experiment_scope() -> None: +def test_conformance_script_is_repository_owned_and_external_tck_independent() -> None: assert 'run_shared_repo_health_prerequisites "conformance"' in CONFORMANCE_TEXT - assert "Run the official A2A TCK as a local/manual experiment." in CONFORMANCE_TEXT - assert "This script is intentionally separate from doctor.sh" in CONFORMANCE_TEXT - assert "DummyChatOpencodeUpstreamClient" in CONFORMANCE_TEXT - assert "failed-tests.json" in CONFORMANCE_TEXT + assert "Run repository-owned A2A 1.0 black-box compatibility probes." in CONFORMANCE_TEXT + assert "does not download or depend on an external TCK" in CONFORMANCE_TEXT + assert "scripts.conformance_probe" in CONFORMANCE_TEXT + assert "scripts.conformance_sut" in CONFORMANCE_TEXT + assert "a2a-tck" not in CONFORMANCE_TEXT + + +def test_conformance_entrypoint_runs_repository_owned_probes(tmp_path: Path) -> None: + with socket.create_server(("127.0.0.1", 0)) as server: + port = server.getsockname()[1] + output_dir = tmp_path / "conformance" + env = { + **os.environ, + "CONFORMANCE_SKIP_REPO_SYNC": "1", + "CONFORMANCE_SUT_PORT": str(port), + "CONFORMANCE_OUTPUT_DIR": str(output_dir), + } + + completed = subprocess.run( + ["bash", "./scripts/conformance.sh"], + check=False, + capture_output=True, + text=True, + timeout=30, + env=env, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + report = json.loads((output_dir / "report.json").read_text()) + assert report["scope"] == "repository-owned-a2a-1.0-compatibility-probes" + assert report["summary"] == {"failed": 0, "passed": 8, "total": 8} def test_smoke_test_requires_explicit_wheel_selection_when_dist_is_ambiguous() -> None: diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 72f5ee3..38c7040 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -752,13 +752,7 @@ def test_agent_card_injects_profile_into_extensions() -> None: "error": None, } } - assert compatibility.params["service_behaviors"]["methods"]["SubscribeToTask"][ - "terminal_state_behavior" - ] == { - "behavior": "replay_terminal_task_once_then_close", - "delivery": "single_task_snapshot", - "closes_stream": True, - } + assert "SubscribeToTask" not in compatibility.params["service_behaviors"]["methods"] assert compatibility.params["protocol_compatibility"] == expected_protocol_compatibility assert compatibility.description.endswith("deployment-conditional methods.") diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index 9099d17..c80f4f4 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -37,6 +37,7 @@ from google.protobuf.json_format import MessageToDict, ParseError import opencode_a2a.server.application as app_module +from opencode_a2a.a2a_protocol import CORE_JSONRPC_METHODS from opencode_a2a.contracts.extensions import ( MODEL_SELECTION_EXTENSION_URI, SESSION_BINDING_EXTENSION_URI, @@ -267,6 +268,19 @@ def test_rest_message_parsing_helpers_cover_upgrade_paths() -> None: } with pytest.raises(InvalidRequestError, match="REST message payload must be a JSON object"): _parse_rest_send_message_request(b"[]") + with pytest.raises(InvalidRequestError, match="message is required"): + _parse_rest_send_message_request(b"{}") + with pytest.raises(InvalidRequestError, match="message.messageId is required"): + _parse_rest_send_message_request( + json.dumps( + { + "message": { + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + } + } + ).encode("utf-8") + ) with pytest.raises( InvalidRequestError, match="REST message payload must use message.parts, not message.content.", @@ -389,6 +403,13 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: "enabled": True, "availability": "always", }, + "metrics_endpoint": { + "enabled": True, + "availability": "enabled", + "path": "/metrics", + "authentication": "required", + "toggle": "A2A_METRICS_ENABLED", + }, }, }, "runtime_context": { @@ -429,8 +450,10 @@ def test_agent_card_helper_builders_cover_optional_branches() -> None: capability_snapshot=capability_snapshot ) ) - assert "authenticated extended Agent Card" in _build_jsonrpc_extension_openapi_description() - assert "opencode.sessions.shell" not in _build_jsonrpc_extension_openapi_description() + jsonrpc_description = _build_jsonrpc_extension_openapi_description() + assert "authenticated extended Agent Card" in jsonrpc_description + assert "opencode.sessions.shell" not in jsonrpc_description + assert all(method in jsonrpc_description for method in CORE_JSONRPC_METHODS) assert "message_send_session_binding" in _build_jsonrpc_extension_openapi_examples() assert "session_shell" not in _build_jsonrpc_extension_openapi_examples() assert "worktrees_create" not in _build_jsonrpc_extension_openapi_examples() @@ -599,6 +622,13 @@ async def close(self) -> None: "enabled": True, "availability": "always", }, + "metrics_endpoint": { + "enabled": True, + "availability": "enabled", + "path": "/metrics", + "authentication": "required", + "toggle": "A2A_METRICS_ENABLED", + }, }, }, }, @@ -671,16 +701,16 @@ async def test_push_notification_routes_are_explicitly_unsupported(monkeypatch) json={"pushNotificationConfig": {"url": "https://example.com/hook"}}, ) - assert response.status_code == 501 + assert response.status_code == 400 assert response.json() == { "error": { - "code": 501, - "status": "UNIMPLEMENTED", + "code": 400, + "status": "FAILED_PRECONDITION", "message": "Push notifications are not supported by the agent", "details": [ { "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "PUSH_NOTIFICATIONS_UNSUPPORTED", + "reason": "PUSH_NOTIFICATION_NOT_SUPPORTED", "domain": "a2a-protocol.org", } ], @@ -688,6 +718,33 @@ async def test_push_notification_routes_are_explicitly_unsupported(monkeypatch) } +@pytest.mark.asyncio +async def test_metrics_endpoint_is_scrapeable_and_authenticated(monkeypatch) -> None: + monkeypatch.setattr( + app_module, + "OpencodeUpstreamClient", + DummyChatOpencodeUpstreamClient, + ) + app = create_app(make_settings(test_bearer_token="test-token")) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + anonymous = await client.get("/metrics") + authenticated = await client.get("/metrics", headers={"Authorization": "Bearer test-token"}) + + assert anonymous.status_code == 401 + assert authenticated.status_code == 200 + assert authenticated.headers["content-type"].startswith("text/plain; version=0.0.4") + + disabled_app = create_app( + make_settings(test_bearer_token="test-token", a2a_metrics_enabled=False) + ) + disabled_transport = httpx.ASGITransport(app=disabled_app) + async with httpx.AsyncClient(transport=disabled_transport, base_url="http://test") as client: + disabled = await client.get("/metrics", headers={"Authorization": "Bearer test-token"}) + assert disabled.status_code == 404 + + @pytest.mark.asyncio async def test_push_notification_jsonrpc_methods_remain_unsupported(monkeypatch) -> None: monkeypatch.setattr( @@ -713,12 +770,12 @@ async def test_push_notification_jsonrpc_methods_remain_unsupported(monkeypatch) assert response.status_code == 200 assert response.json() == { "error": { - "code": -32004, - "message": "This operation is not supported", + "code": -32003, + "message": "Push Notification is not supported", "data": [ { "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "UNSUPPORTED_OPERATION", + "reason": "PUSH_NOTIFICATION_NOT_SUPPORTED", "domain": "a2a-protocol.org", } ], @@ -789,8 +846,9 @@ async def _consume_canceled(_self, _consumer): # noqa: ANN001 assert events == [] task_store.get = AsyncMock(return_value=canceled_task) - events = [item async for item in handler.on_subscribe_to_task(subscribe_params)] - assert events == [canceled_task] + with pytest.raises(UnsupportedOperationError): + async for _item in handler.on_subscribe_to_task(subscribe_params): + pass task_store.get = AsyncMock(return_value=working_task) diff --git a/tests/server/test_cancel_contract.py b/tests/server/test_cancel_contract.py index 7c0a27d..b12782e 100644 --- a/tests/server/test_cancel_contract.py +++ b/tests/server/test_cancel_contract.py @@ -15,6 +15,7 @@ TaskNotFoundError, TaskState, TaskStatus, + UnsupportedOperationError, ) from opencode_a2a.server.application import OpencodeRequestHandler @@ -117,7 +118,7 @@ async def _consume_non_canceled(_self, _consumer): # noqa: ANN001 @pytest.mark.asyncio -async def test_resubscribe_terminal_task_replays_final_snapshot_once() -> None: +async def test_subscribe_terminal_task_is_unsupported() -> None: executor = AsyncMock() store = _store() handler = OpencodeRequestHandler( @@ -128,13 +129,9 @@ async def test_resubscribe_terminal_task_replays_final_snapshot_once() -> None: task = _task(task_id="task-3", context_id="ctx-3", state=TaskState.TASK_STATE_CANCELED) await store.save(task, None) - events = [] - async for event in handler.on_subscribe_to_task(SubscribeToTaskRequest(id="task-3")): - events.append(event) - - assert len(events) == 1 - assert isinstance(events[0], Task) - assert events[0].status.state == TaskState.TASK_STATE_CANCELED + with pytest.raises(UnsupportedOperationError): + async for _event in handler.on_subscribe_to_task(SubscribeToTaskRequest(id="task-3")): + pass @pytest.mark.asyncio diff --git a/tests/server/test_output_negotiation.py b/tests/server/test_output_negotiation.py index 6b59d3f..7292514 100644 --- a/tests/server/test_output_negotiation.py +++ b/tests/server/test_output_negotiation.py @@ -4,7 +4,7 @@ import pytest from a2a.server.context import ServerCallContext -from a2a.server.events import EventConsumer, EventQueue +from a2a.server.events import EventConsumer, InMemoryQueueManager from a2a.server.tasks import TaskManager from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore from a2a.types import ( @@ -21,6 +21,7 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, + UnsupportedOperationError, ) from opencode_a2a.a2a_utils import make_data_part @@ -238,7 +239,7 @@ async def test_negotiating_result_aggregator_persists_metadata_for_artifact_firs initial_message=None, ) aggregator = NegotiatingResultAggregator(task_manager, ["text/plain"]) - queue = EventQueue() + queue = await InMemoryQueueManager().create_or_tap("task-artifact-first") await queue.enqueue_event( TaskArtifactUpdateEvent( @@ -289,7 +290,7 @@ async def test_negotiating_result_aggregator_compacts_stream_artifacts_for_persi initial_message=None, ) aggregator = NegotiatingResultAggregator(task_manager, None) - queue = EventQueue() + queue = await InMemoryQueueManager().create_or_tap("task-stream-compact") stream_metadata = { "shared": { "stream": { @@ -359,7 +360,7 @@ async def test_negotiating_result_aggregator_persists_terminal_usage_with_final_ initial_message=None, ) aggregator = NegotiatingResultAggregator(task_manager, None) - queue = EventQueue() + queue = await InMemoryQueueManager().create_or_tap("task-stream-terminal-contract") stream_metadata = { "shared": { "stream": { @@ -438,7 +439,7 @@ async def test_on_get_task_applies_persisted_output_negotiation() -> None: @pytest.mark.asyncio -async def test_resubscribe_terminal_task_applies_persisted_output_negotiation() -> None: +async def test_subscribe_terminal_task_is_unsupported_before_output_negotiation() -> None: store = _store() task = _task_with_negotiated_outputs(task_id="task-resub", context_id="ctx-resub") await store.save(task, ServerCallContext()) @@ -448,19 +449,9 @@ async def test_resubscribe_terminal_task_applies_persisted_output_negotiation() agent_card=_agent_card(), ) - events = [] - async for event in handler.on_subscribe_to_task(SubscribeToTaskRequest(id="task-resub")): - events.append(event) - - assert len(events) == 1 - assert isinstance(events[0], Task) - assert events[0].artifacts is not None - assert [artifact.artifact_id for artifact in events[0].artifacts] == [ - "task-resub:text", - "task-resub:json", - ] - assert events[0].artifacts[1].parts[0].HasField("text") - assert events[0].artifacts[1].parts[0].text == '{"status":"completed","tool":"bash"}' + with pytest.raises(UnsupportedOperationError): + async for _event in handler.on_subscribe_to_task(SubscribeToTaskRequest(id="task-resub")): + pass @pytest.mark.asyncio diff --git a/tests/server/test_runtime_limits.py b/tests/server/test_runtime_limits.py index 0470530..d9ac8e9 100644 --- a/tests/server/test_runtime_limits.py +++ b/tests/server/test_runtime_limits.py @@ -5,6 +5,7 @@ import httpx import pytest +from a2a.types import TaskState from opencode_a2a.server.runtime_limits import ( SlidingWindowRateLimiter, @@ -434,6 +435,7 @@ async def test_streaming_byte_budget_integration_rejects_subscribe_before_sse(mo _task_for_listing( task_id="task-sub", context_id="ctx-sub", + state=TaskState.TASK_STATE_WORKING, timestamp="2026-08-22T00:00:00+00:00", ), _authenticated_task_context(),