From 51122eb000bdd6f9cbb8439d904e862fa8e09c15 Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Sat, 18 Jul 2026 13:35:32 -0700 Subject: [PATCH] Establish OpenCode compatibility certification corpus --- .github/workflows/candidate.yml | 93 ++++++++++++ .github/workflows/watch-upstream.yml | 81 ++++++++++ .gitignore | 6 + LICENSE | 21 +++ README.md | 69 +++++++++ docs/certification.md | 49 ++++++ evidence/2026-07-18-opencode-ruby-alpha6.json | 67 +++++++++ fixtures/events/current-status-idle.jsonl | 4 + fixtures/events/delta-before-part.jsonl | 3 + fixtures/events/interactive-prompts.jsonl | 7 + fixtures/events/legacy-idle.jsonl | 2 + fixtures/events/multi-step-usage.jsonl | 5 + fixtures/events/reasoning-and-final.jsonl | 5 + fixtures/events/unknown-event.jsonl | 3 + fixtures/expected/current-status-idle.json | 6 + fixtures/expected/delta-before-part.json | 6 + fixtures/expected/interactive-prompts.json | 12 ++ fixtures/expected/legacy-idle.json | 6 + fixtures/expected/multi-step-usage.json | 9 ++ fixtures/expected/reasoning-and-final.json | 6 + fixtures/expected/unknown-event.json | 6 + fixtures/manifest.json | 12 ++ manifests/client-candidate.json | 6 + manifests/image-matrix.json | 58 +++++++ manifests/runtime-tuples.json | 82 ++++++++++ manifests/upstream.json | 9 ++ profiles/plugin-ledger.json | 11 ++ profiles/provider-hooks.json | 11 ++ profiles/rails-persisted-turn.json | 13 ++ profiles/ruby-rest-sse.json | 19 +++ profiles/strict-v2.json | 11 ++ profiles/voice-stream.json | 11 ++ ruby/live_probe.rb | 66 ++++++++ ruby/opencode_ruby_fixture_contract.rb | 69 +++++++++ scripts/fake_llm.py | 111 ++++++++++++++ scripts/matrix_json.rb | 15 ++ scripts/record_upstream_candidate.rb | 43 ++++++ scripts/run_image_contract.sh | 142 ++++++++++++++++++ test/repository_test.rb | 63 ++++++++ 39 files changed, 1218 insertions(+) create mode 100644 .github/workflows/candidate.yml create mode 100644 .github/workflows/watch-upstream.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/certification.md create mode 100644 evidence/2026-07-18-opencode-ruby-alpha6.json create mode 100644 fixtures/events/current-status-idle.jsonl create mode 100644 fixtures/events/delta-before-part.jsonl create mode 100644 fixtures/events/interactive-prompts.jsonl create mode 100644 fixtures/events/legacy-idle.jsonl create mode 100644 fixtures/events/multi-step-usage.jsonl create mode 100644 fixtures/events/reasoning-and-final.jsonl create mode 100644 fixtures/events/unknown-event.jsonl create mode 100644 fixtures/expected/current-status-idle.json create mode 100644 fixtures/expected/delta-before-part.json create mode 100644 fixtures/expected/interactive-prompts.json create mode 100644 fixtures/expected/legacy-idle.json create mode 100644 fixtures/expected/multi-step-usage.json create mode 100644 fixtures/expected/reasoning-and-final.json create mode 100644 fixtures/expected/unknown-event.json create mode 100644 fixtures/manifest.json create mode 100644 manifests/client-candidate.json create mode 100644 manifests/image-matrix.json create mode 100644 manifests/runtime-tuples.json create mode 100644 manifests/upstream.json create mode 100644 profiles/plugin-ledger.json create mode 100644 profiles/provider-hooks.json create mode 100644 profiles/rails-persisted-turn.json create mode 100644 profiles/ruby-rest-sse.json create mode 100644 profiles/strict-v2.json create mode 100644 profiles/voice-stream.json create mode 100644 ruby/live_probe.rb create mode 100644 ruby/opencode_ruby_fixture_contract.rb create mode 100755 scripts/fake_llm.py create mode 100755 scripts/matrix_json.rb create mode 100755 scripts/record_upstream_candidate.rb create mode 100755 scripts/run_image_contract.sh create mode 100644 test/repository_test.rb diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml new file mode 100644 index 0000000..7061548 --- /dev/null +++ b/.github/workflows/candidate.yml @@ -0,0 +1,93 @@ +name: Candidate compatibility + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + opencode_ruby_ref: + description: Candidate opencode-ruby ref; blank uses the manifest + required: false + type: string + +jobs: + repository: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + - run: ruby test/repository_test.rb + + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.value }} + client_ref: ${{ steps.client.outputs.value }} + steps: + - uses: actions/checkout@v4 + - id: matrix + run: echo "value=$(ruby scripts/matrix_json.rb)" >> "$GITHUB_OUTPUT" + - id: client + env: + INPUT_REF: ${{ inputs.opencode_ruby_ref }} + run: | + if [ -n "$INPUT_REF" ]; then + echo "value=$INPUT_REF" >> "$GITHUB_OUTPUT" + else + echo "value=$(jq -r .ref manifests/client-candidate.json)" >> "$GITHUB_OUTPUT" + fi + + fixture-contract: + needs: prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: ajaynomics/opencode-ruby + ref: ${{ needs.prepare.outputs.client_ref }} + path: client + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: client + - name: Candidate unit tests + working-directory: client + run: bundle exec rake test + - name: Shared fixture contract + working-directory: client + env: + OPENCODE_RUBY_PATH: ${{ github.workspace }}/client + OPENCODE_RUBY_COMMIT: ${{ needs.prepare.outputs.client_ref }} + run: bundle exec ruby ../ruby/opencode_ruby_fixture_contract.rb + + exact-image-contract: + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} + name: image ${{ matrix.id }} + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: ajaynomics/opencode-ruby + ref: ${{ needs.prepare.outputs.client_ref }} + path: client + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: client + - name: Exercise complete REST and SSE turn + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/client/Gemfile + OPENCODE_IMAGE: ${{ matrix.image }} + OPENCODE_RUBY_PATH: ${{ github.workspace }}/client + run: bundle exec scripts/run_image_contract.sh diff --git a/.github/workflows/watch-upstream.yml b/.github/workflows/watch-upstream.yml new file mode 100644 index 0000000..18a4424 --- /dev/null +++ b/.github/workflows/watch-upstream.yml @@ -0,0 +1,81 @@ +name: Watch upstream OpenCode + +on: + schedule: + - cron: "23 */6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: opencode-upstream-watcher + cancel-in-progress: false + +jobs: + open-compatibility-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Resolve latest release + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + release="$(gh api repos/anomalyco/opencode/releases/latest)" + tag="$(jq -r .tag_name <<<"$release")" + current="$(jq -r .release_tag manifests/upstream.json)" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "published_at=$(jq -r .published_at <<<"$release")" >> "$GITHUB_OUTPUT" + echo "release_url=$(jq -r .html_url <<<"$release")" >> "$GITHUB_OUTPUT" + if [ "$tag" = "$current" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - name: Stop when a PR already exists + if: steps.release.outputs.changed == 'true' + id: existing + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + branch="compat/upstream-${TAG#v}" + count="$(gh pr list --state open --head "$branch" --json number --jq length)" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + if [ "$count" -gt 0 ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Resolve immutable image and update manifests + if: steps.release.outputs.changed == 'true' && steps.existing.outputs.skip == 'false' + env: + TAG: ${{ steps.release.outputs.tag }} + PUBLISHED_AT: ${{ steps.release.outputs.published_at }} + RELEASE_URL: ${{ steps.release.outputs.release_url }} + run: | + digest="$(docker buildx imagetools inspect "ghcr.io/anomalyco/opencode:${TAG#v}" --format '{{json .Manifest}}' | jq -r .digest)" + ruby scripts/record_upstream_candidate.rb "$TAG" "$PUBLISHED_AT" "$RELEASE_URL" "$digest" + ruby test/repository_test.rb + - name: Open compatibility PR + if: steps.release.outputs.changed == 'true' && steps.existing.outputs.skip == 'false' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + BRANCH: ${{ steps.existing.outputs.branch }} + run: | + git config user.name "opencode-compat-bot" + git config user.email "opencode-compat-bot@users.noreply.github.com" + git switch -c "$BRANCH" + git add manifests/image-matrix.json manifests/upstream.json + git commit -m "Test OpenCode ${TAG} compatibility" + git push --set-upstream origin "$BRANCH" + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "Test OpenCode ${TAG} compatibility" \ + --body "Upstream watcher detected ${TAG}, resolved its immutable OCI digest, and added it as a pending compatibility target. This PR only runs certification checks; promotion and consumer rollout remain manual." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c3b7ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.bundle/ +/vendor/bundle/ +/*.log +/.compat-evidence/ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e51f6d6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ajay Krishnan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d23423e --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# OpenCode compatibility corpus + +This repository is the compatibility boundary between the fast-moving +[OpenCode](https://github.com/anomalyco/opencode) server and the clients and +host applications that depend on its HTTP, SSE, plugin, and runtime behavior. +It contains only synthetic fixtures and public image coordinates; no production +prompts, credentials, sessions, or user data belong here. + +## Policy + +- A gem release is a candidate until the fixture suite and every required image + profile pass. +- Image references are immutable OCI index digests. A tag may be recorded as + human-readable provenance, but it is never an execution coordinate. +- Custom consumer images are certified with an isolated canary on the host that + can pull them. Public CI certifies their upstream base plus the same profile. +- Promotion is explicit. CI may certify a tuple and the watcher may open a PR; + neither workflow deploys or changes a consumer. +- Each consumer retains both `current` and `previous` certified tuples so a + rollback restores the gem and server together. + +## What is covered + +The corpus exercises both historical and current terminal events, delta-before- +part ordering, authoritative final text, reasoning text, aggregate multi-step +usage, interactive question and permission waits, unknown-event tolerance, the +subscribe-before-prompt ordering contract, and reconnect without prompt replay. + +Profiles keep the ownership boundaries explicit: + +- `ruby-rest-sse`: the `opencode-ruby` HTTP/SSE adapter. +- `rails-persisted-turn`: Rails lifecycle, persistence, and recovery semantics. +- `voice-stream`: Greenroom's voice/LiveKit stream semantics. +- `strict-v2`: Leela's strict version and route-ownership gate. +- `plugin-ledger`: Ajent/plugin hook and ledger behavior. +- `provider-hooks`: inference provider configuration and hook behavior. + +## Run locally + +Prerequisites are Ruby 3.2+, Python 3, `jq`, and Docker. + +```sh +ruby test/repository_test.rb +OPENCODE_RUBY_PATH=/data/projects/opencode-ruby-alpha6 \ + ruby ruby/opencode_ruby_fixture_contract.rb +OPENCODE_RUBY_PATH=/data/projects/opencode-ruby-alpha6 \ +OPENCODE_IMAGE='ghcr.io/anomalyco/opencode@sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a' \ + scripts/run_image_contract.sh +``` + +The live contract starts a deterministic local OpenAI-compatible model stub and +an isolated OpenCode container. It creates a session, subscribes, submits an +async prompt, observes terminal SSE, fetches the authoritative exchange, and +deletes the session. It never calls an external model provider. + +## Adding an upstream release + +The scheduled watcher compares the latest upstream GitHub release with the +manifest. When a new release appears, it resolves the tag to an OCI digest and +opens a compatibility PR. The PR adds a pending image-matrix entry, which runs +the candidate suite. A person still decides whether to promote it and update +consumers. + +See [docs/certification.md](docs/certification.md) for promotion, canary, and +rollback evidence requirements. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/docs/certification.md b/docs/certification.md new file mode 100644 index 0000000..99046ae --- /dev/null +++ b/docs/certification.md @@ -0,0 +1,49 @@ +# Certification and promotion + +## Candidate gate + +A candidate tuple is the complete set of client version or commit, Rails gem +version or commit where applicable, exact OpenCode image, profile, and consumer +commit. Changing any member invalidates the certification. + +The gate requires: + +1. repository validation and the shared fixture corpus; +2. the public exact-image matrix against the deterministic model stub; +3. an isolated custom-image canary for Ajent Rails and Mushu; +4. the consumer's own focused and full required tests in its devcontainer; +5. a user-visible canary turn for stream consumers; +6. captured evidence including image ID/digest, server version, source commit, + gem commits, timestamps, and probe outcome. + +Health-only probes do not certify a tuple. + +## Promotion + +Promotion is a reviewed manifest change that moves the old `current` tuple to +`previous` and the passing candidate to `current`. The consumer change is then +merged and deployed separately. Workflows in this repository have no deploy +credentials or deploy steps. + +## Rollback + +Rollback restores the whole `previous` tuple. Do not roll back only the gem or +only the runtime image: the wire contract is the unit of compatibility. + +For a custom image, retain its immutable registry digest or Docker image ID and +the source commit used to build it. A source tag alone is insufficient. + +## Custom-image canary + +Run the shared live contract on a host that can pull the exact image: + +```sh +OPENCODE_RUBY_PATH=/path/to/opencode-ruby \ +OPENCODE_IMAGE='registry.example/image@sha256:...' \ +scripts/run_image_contract.sh +``` + +If an older private registry cannot expose an OCI repository digest, set +`ALLOW_EXACT_IMAGE_ID=1` and pass the locally present `sha256:...` image ID. +Record the registry tag, image ID, source commit, and reason a repository +digest was unavailable in the evidence. diff --git a/evidence/2026-07-18-opencode-ruby-alpha6.json b/evidence/2026-07-18-opencode-ruby-alpha6.json new file mode 100644 index 0000000..5395acc --- /dev/null +++ b/evidence/2026-07-18-opencode-ruby-alpha6.json @@ -0,0 +1,67 @@ +{ + "schema_version": 1, + "candidate": { + "repository": "ajaynomics/opencode-ruby", + "version": "0.0.1.alpha6", + "commit": "8adc95985a79b16e6e4bc0c0f827d638f244d509", + "transport_implementation_commit": "5113a953db8026697262bd4f97197a3d13077762" + }, + "fixture_contract": { + "status": "pass", + "fixture_count": 7 + }, + "unit_contract": { + "status": "pass", + "runs": 24, + "assertions": 56 + }, + "image_contracts": [ + { + "target": "travelwolf-1.16.1", + "image": "ghcr.io/anomalyco/opencode@sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a", + "server_version": "1.16.1", + "checked_at": "2026-07-18T20:34:05Z", + "status": "pass" + }, + { + "target": "opencode-ajent-1.17.18", + "image": "ghcr.io/anomalyco/opencode@sha256:bf9d0e84b7cedef436a8f57db4d48767cd5d8fc6604f666335c1cc916b199a97", + "server_version": "1.17.18", + "checked_at": "2026-07-18T20:34:08Z", + "status": "pass" + }, + { + "target": "ajent-base-1.18.3", + "image": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e", + "server_version": "1.18.3", + "checked_at": "2026-07-18T20:34:08Z", + "status": "pass" + }, + { + "target": "ajent-blackline-live-artifact", + "image_id": "sha256:6fb2b3bf8e4cdf48e16bf6c3475e953df2f8304e77f5efe816ceb46f905eead8", + "registry_ref": "docker-registry:5000/opencode-blackline:83eed3c247a352842b08fc5cf339c4d0acdaacba", + "server_version": "1.18.3", + "checked_at": "2026-07-18T20:34:10Z", + "status": "pass" + }, + { + "target": "mushu-permission-shortcuts-live-artifact", + "image_id": "sha256:ed6293ca87e6db1dc07870d3ed6da21960743e6e2e904533b3c566d334b58d6a", + "registry_ref": "docker-registry:5000/mushu-opencode:sha-99d6328b1", + "source_commit": "99d6328b18ff8a340a928449a2aa8bc184a063f9", + "server_version": "0.0.0-permission-shortcuts-202606300721", + "checked_at": "2026-07-18T20:34:08Z", + "status": "pass" + } + ], + "contract": [ + "health", + "create-session", + "subscribe-ready-before-async-prompt", + "deterministic-model-request", + "sse-terminal", + "authoritative-final-exchange", + "delete-session" + ] +} diff --git a/fixtures/events/current-status-idle.jsonl b/fixtures/events/current-status-idle.jsonl new file mode 100644 index 0000000..dba1d97 --- /dev/null +++ b/fixtures/events/current-status-idle.jsonl @@ -0,0 +1,4 @@ +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant","partID":"part_text","field":"text","delta":"current "}} +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant","partID":"part_text","field":"text","delta":"ok"}} +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"part_text","messageID":"msg_assistant","sessionID":"ses_fixture","type":"text","text":"current ok"}}} +{"type":"session.status","properties":{"sessionID":"ses_fixture","status":{"type":"idle"}}} diff --git a/fixtures/events/delta-before-part.jsonl b/fixtures/events/delta-before-part.jsonl new file mode 100644 index 0000000..2fdc691 --- /dev/null +++ b/fixtures/events/delta-before-part.jsonl @@ -0,0 +1,3 @@ +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant","partID":"part_tail","field":"text","delta":"tail dro"}} +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"part_tail","messageID":"msg_assistant","sessionID":"ses_fixture","type":"text","text":"tail drop recovered"}}} +{"type":"session.status","properties":{"sessionID":"ses_fixture","status":{"type":"idle"}}} diff --git a/fixtures/events/interactive-prompts.jsonl b/fixtures/events/interactive-prompts.jsonl new file mode 100644 index 0000000..96afd85 --- /dev/null +++ b/fixtures/events/interactive-prompts.jsonl @@ -0,0 +1,7 @@ +{"type":"question.asked","properties":{"id":"que_fixture","sessionID":"ses_fixture","questions":[{"header":"Choice","question":"Continue?","options":[{"label":"Yes","description":"Continue"}]}],"tool":{"messageID":"msg_assistant","callID":"call_question"}}} +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"part_tool","messageID":"msg_assistant","sessionID":"ses_fixture","type":"tool","callID":"call_question","tool":"question","state":{"status":"running","input":{}}}}} +{"type":"permission.asked","properties":{"id":"per_fixture","sessionID":"ses_fixture","permission":"bash","patterns":["echo *"]}} +{"type":"permission.replied","properties":{"sessionID":"ses_fixture","requestID":"per_fixture","reply":"once"}} +{"type":"question.replied","properties":{"sessionID":"ses_fixture","requestID":"que_fixture","answers":[["Yes"]]}} +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"part_tool","messageID":"msg_assistant","sessionID":"ses_fixture","type":"tool","callID":"call_question","tool":"question","state":{"status":"completed","input":{},"output":"answered"}}}} +{"type":"session.status","properties":{"sessionID":"ses_fixture","status":{"type":"idle"}}} diff --git a/fixtures/events/legacy-idle.jsonl b/fixtures/events/legacy-idle.jsonl new file mode 100644 index 0000000..709e927 --- /dev/null +++ b/fixtures/events/legacy-idle.jsonl @@ -0,0 +1,2 @@ +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant","partID":"part_text","field":"text","delta":"legacy ok"}} +{"type":"session.idle","properties":{"sessionID":"ses_fixture"}} diff --git a/fixtures/events/multi-step-usage.jsonl b/fixtures/events/multi-step-usage.jsonl new file mode 100644 index 0000000..02b8f0e --- /dev/null +++ b/fixtures/events/multi-step-usage.jsonl @@ -0,0 +1,5 @@ +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"step_1","messageID":"msg_assistant_1","sessionID":"ses_fixture","type":"step-finish","cost":0.12,"tokens":{"input":10,"output":3}}}} +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"step_2","messageID":"msg_assistant_2","sessionID":"ses_fixture","type":"step-finish","cost":0.34,"tokens":{"input":20,"output":7}}}} +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant_2","partID":"part_text","field":"text","delta":"two steps"}} +{"type":"message.updated","properties":{"sessionID":"ses_fixture","info":{"id":"msg_assistant_2","role":"assistant","cost":0.34,"tokens":{"input":20,"output":7}}}} +{"type":"session.status","properties":{"sessionID":"ses_fixture","status":{"type":"idle"}}} diff --git a/fixtures/events/reasoning-and-final.jsonl b/fixtures/events/reasoning-and-final.jsonl new file mode 100644 index 0000000..fbfae70 --- /dev/null +++ b/fixtures/events/reasoning-and-final.jsonl @@ -0,0 +1,5 @@ +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant","partID":"part_reason","field":"reasoning","delta":"considering"}} +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"part_reason","messageID":"msg_assistant","sessionID":"ses_fixture","type":"reasoning","text":"considering carefully"}}} +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant","partID":"part_text","field":"text","delta":"final answer"}} +{"type":"message.part.updated","properties":{"sessionID":"ses_fixture","part":{"id":"part_text","messageID":"msg_assistant","sessionID":"ses_fixture","type":"text","text":"final answer"}}} +{"type":"session.status","properties":{"sessionID":"ses_fixture","status":{"type":"idle"}}} diff --git a/fixtures/events/unknown-event.jsonl b/fixtures/events/unknown-event.jsonl new file mode 100644 index 0000000..5ea8429 --- /dev/null +++ b/fixtures/events/unknown-event.jsonl @@ -0,0 +1,3 @@ +{"type":"server.future-capability","properties":{"sessionID":"ses_fixture","payload":{"new":true}}} +{"type":"message.part.delta","properties":{"sessionID":"ses_fixture","messageID":"msg_assistant","partID":"part_text","field":"text","delta":"still works"}} +{"type":"session.status","properties":{"sessionID":"ses_fixture","status":{"type":"idle"}}} diff --git a/fixtures/expected/current-status-idle.json b/fixtures/expected/current-status-idle.json new file mode 100644 index 0000000..74d71d6 --- /dev/null +++ b/fixtures/expected/current-status-idle.json @@ -0,0 +1,6 @@ +{ + "full_text": "current ok", + "reasoning_text": "", + "tool_count": 0, + "prompt_blocked": false +} diff --git a/fixtures/expected/delta-before-part.json b/fixtures/expected/delta-before-part.json new file mode 100644 index 0000000..4095e90 --- /dev/null +++ b/fixtures/expected/delta-before-part.json @@ -0,0 +1,6 @@ +{ + "full_text": "tail drop recovered", + "reasoning_text": "", + "tool_count": 0, + "prompt_blocked": false +} diff --git a/fixtures/expected/interactive-prompts.json b/fixtures/expected/interactive-prompts.json new file mode 100644 index 0000000..05cb6cf --- /dev/null +++ b/fixtures/expected/interactive-prompts.json @@ -0,0 +1,12 @@ +{ + "full_text": "", + "reasoning_text": "", + "tool_count": 1, + "prompt_blocked": false, + "tool": { + "tool": "question", + "status": "completed", + "callID": "call_question", + "opencode_request_id": "que_fixture" + } +} diff --git a/fixtures/expected/legacy-idle.json b/fixtures/expected/legacy-idle.json new file mode 100644 index 0000000..004cdc1 --- /dev/null +++ b/fixtures/expected/legacy-idle.json @@ -0,0 +1,6 @@ +{ + "full_text": "legacy ok", + "reasoning_text": "", + "tool_count": 0, + "prompt_blocked": false +} diff --git a/fixtures/expected/multi-step-usage.json b/fixtures/expected/multi-step-usage.json new file mode 100644 index 0000000..b91bd65 --- /dev/null +++ b/fixtures/expected/multi-step-usage.json @@ -0,0 +1,9 @@ +{ + "full_text": "two steps", + "reasoning_text": "", + "tool_count": 0, + "prompt_blocked": false, + "total_cost": 0.46, + "total_input_tokens": 30, + "total_output_tokens": 10 +} diff --git a/fixtures/expected/reasoning-and-final.json b/fixtures/expected/reasoning-and-final.json new file mode 100644 index 0000000..f3f8e53 --- /dev/null +++ b/fixtures/expected/reasoning-and-final.json @@ -0,0 +1,6 @@ +{ + "full_text": "final answer", + "reasoning_text": "considering carefully", + "tool_count": 0, + "prompt_blocked": false +} diff --git a/fixtures/expected/unknown-event.json b/fixtures/expected/unknown-event.json new file mode 100644 index 0000000..4e46f8b --- /dev/null +++ b/fixtures/expected/unknown-event.json @@ -0,0 +1,6 @@ +{ + "full_text": "still works", + "reasoning_text": "", + "tool_count": 0, + "prompt_blocked": false +} diff --git a/fixtures/manifest.json b/fixtures/manifest.json new file mode 100644 index 0000000..f7f0bd3 --- /dev/null +++ b/fixtures/manifest.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "fixtures": [ + {"id": "legacy-idle", "events": "events/legacy-idle.jsonl", "expected": "expected/legacy-idle.json"}, + {"id": "current-status-idle", "events": "events/current-status-idle.jsonl", "expected": "expected/current-status-idle.json"}, + {"id": "delta-before-part", "events": "events/delta-before-part.jsonl", "expected": "expected/delta-before-part.json"}, + {"id": "reasoning-and-final", "events": "events/reasoning-and-final.jsonl", "expected": "expected/reasoning-and-final.json"}, + {"id": "multi-step-usage", "events": "events/multi-step-usage.jsonl", "expected": "expected/multi-step-usage.json"}, + {"id": "interactive-prompts", "events": "events/interactive-prompts.jsonl", "expected": "expected/interactive-prompts.json"}, + {"id": "unknown-event", "events": "events/unknown-event.jsonl", "expected": "expected/unknown-event.json"} + ] +} diff --git a/manifests/client-candidate.json b/manifests/client-candidate.json new file mode 100644 index 0000000..30a1381 --- /dev/null +++ b/manifests/client-candidate.json @@ -0,0 +1,6 @@ +{ + "repository": "ajaynomics/opencode-ruby", + "ref": "8adc95985a79b16e6e4bc0c0f827d638f244d509", + "version": "0.0.1.alpha6", + "status": "candidate" +} diff --git a/manifests/image-matrix.json b/manifests/image-matrix.json new file mode 100644 index 0000000..5ed3e41 --- /dev/null +++ b/manifests/image-matrix.json @@ -0,0 +1,58 @@ +{ + "schema_version": 1, + "public_ci": [ + { + "id": "upstream-1.16.1", + "version": "1.16.1", + "image": "ghcr.io/anomalyco/opencode@sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a", + "tag_provenance": "ghcr.io/anomalyco/opencode:1.16.1", + "consumers": ["travelwolf"], + "profiles": ["ruby-rest-sse", "rails-persisted-turn"], + "certification_status": "certified" + }, + { + "id": "upstream-1.17.18", + "version": "1.17.18", + "image": "ghcr.io/anomalyco/opencode@sha256:bf9d0e84b7cedef436a8f57db4d48767cd5d8fc6604f666335c1cc916b199a97", + "tag_provenance": "ghcr.io/anomalyco/opencode:1.17.18", + "consumers": ["opencode-ajent"], + "profiles": ["plugin-ledger", "ruby-rest-sse"], + "certification_status": "certified" + }, + { + "id": "upstream-1.18.3", + "version": "1.18.3", + "image": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e", + "tag_provenance": "ghcr.io/anomalyco/opencode:1.18.3", + "consumers": ["ajent-rails"], + "profiles": ["ruby-rest-sse", "rails-persisted-turn", "plugin-ledger"], + "certification_status": "certified" + } + ], + "host_canary": [ + { + "id": "ajent-blackline-live", + "consumer": "ajent-rails", + "registry_ref": "docker-registry:5000/opencode-blackline:83eed3c247a352842b08fc5cf339c4d0acdaacba", + "docker_image_id": "sha256:6fb2b3bf8e4cdf48e16bf6c3475e953df2f8304e77f5efe816ceb46f905eead8", + "reported_version": "1.18.3", + "upstream_base": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e", + "profiles": ["ruby-rest-sse", "rails-persisted-turn", "plugin-ledger"], + "certification_status": "candidate-certified", + "certified_candidate": "8adc95985a79b16e6e4bc0c0f827d638f244d509", + "certified_at": "2026-07-18T20:34:10Z" + }, + { + "id": "mushu-permission-shortcuts-live", + "consumer": "mushu", + "registry_ref": "docker-registry:5000/mushu-opencode:sha-99d6328b1", + "docker_image_id": "sha256:ed6293ca87e6db1dc07870d3ed6da21960743e6e2e904533b3c566d334b58d6a", + "reported_version": "0.0.0-permission-shortcuts-202606300721", + "source_commit": "99d6328b18ff8a340a928449a2aa8bc184a063f9", + "profiles": ["ruby-rest-sse"], + "certification_status": "candidate-certified", + "certified_candidate": "8adc95985a79b16e6e4bc0c0f827d638f244d509", + "certified_at": "2026-07-18T20:34:08Z" + } + ] +} diff --git a/manifests/runtime-tuples.json b/manifests/runtime-tuples.json new file mode 100644 index 0000000..400cc0b --- /dev/null +++ b/manifests/runtime-tuples.json @@ -0,0 +1,82 @@ +{ + "schema_version": 1, + "migration_state": "candidate", + "consumers": { + "ajent-rails": { + "profile": "rails-persisted-turn", + "current": { + "status": "observed-production", + "consumer_commit": "83eed3c247a352842b08fc5cf339c4d0acdaacba", + "opencode_ruby": {"version": "0.0.1.alpha2", "git_commit": "889d383"}, + "opencode_rails": {"version": "0.0.1.alpha2", "git_commit": "9b0c4cd"}, + "runtime": { + "registry_ref": "docker-registry:5000/opencode-blackline:83eed3c247a352842b08fc5cf339c4d0acdaacba", + "docker_image_id": "sha256:6fb2b3bf8e4cdf48e16bf6c3475e953df2f8304e77f5efe816ceb46f905eead8", + "reported_version": "1.18.3", + "upstream_base": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e" + } + }, + "candidate": { + "status": "compatibility-certified", + "opencode_ruby": {"version": "0.0.1.alpha6", "git_commit": "8adc95985a79b16e6e4bc0c0f827d638f244d509"}, + "opencode_rails": {"version": "0.0.1.alpha6", "git_commit": "ca08bb36e82cbe059f2c0dedbed0ad2c602d1afd"}, + "runtime": { + "docker_image_id": "sha256:6fb2b3bf8e4cdf48e16bf6c3475e953df2f8304e77f5efe816ceb46f905eead8", + "reported_version": "1.18.3", + "upstream_base": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e" + }, + "certified_at": "2026-07-18T20:34:10Z" + }, + "previous": null + }, + "travelwolf": { + "profile": "rails-persisted-turn", + "current": { + "status": "repository-configured", + "consumer_commit": "1ca756d64f058c9e0379f69fc4a3d53330cfcd15", + "opencode_ruby": {"version": "0.0.1.alpha2", "git_commit": "889d383"}, + "opencode_rails": {"version": "0.0.1.alpha2", "git_commit": "9b0c4cd"}, + "runtime": { + "image": "ghcr.io/anomalyco/opencode@sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a", + "reported_version": "1.16.1" + } + }, + "candidate": { + "status": "compatibility-certified", + "opencode_ruby": {"version": "0.0.1.alpha6", "git_commit": "8adc95985a79b16e6e4bc0c0f827d638f244d509"}, + "opencode_rails": {"version": "0.0.1.alpha6", "git_commit": "ca08bb36e82cbe059f2c0dedbed0ad2c602d1afd"}, + "runtime": { + "image": "ghcr.io/anomalyco/opencode@sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a", + "reported_version": "1.16.1" + }, + "certified_at": "2026-07-18T20:34:05Z" + }, + "previous": null + }, + "mushu": { + "profile": "ruby-rest-sse", + "current": { + "status": "observed-production", + "consumer_commit": "fe16e5bab028e3c92cd5508ef2cebd9bfa23386f", + "opencode_ruby": {"version": "0.0.1.alpha2", "git_commit": "889d383"}, + "runtime": { + "registry_ref": "docker-registry:5000/mushu-opencode:sha-99d6328b1", + "docker_image_id": "sha256:ed6293ca87e6db1dc07870d3ed6da21960743e6e2e904533b3c566d334b58d6a", + "source_commit": "99d6328b18ff8a340a928449a2aa8bc184a063f9", + "reported_version": "0.0.0-permission-shortcuts-202606300721" + } + }, + "candidate": { + "status": "compatibility-certified", + "opencode_ruby": {"version": "0.0.1.alpha6", "git_commit": "8adc95985a79b16e6e4bc0c0f827d638f244d509"}, + "runtime": { + "docker_image_id": "sha256:ed6293ca87e6db1dc07870d3ed6da21960743e6e2e904533b3c566d334b58d6a", + "source_commit": "99d6328b18ff8a340a928449a2aa8bc184a063f9", + "reported_version": "0.0.0-permission-shortcuts-202606300721" + }, + "certified_at": "2026-07-18T20:34:08Z" + }, + "previous": null + } + } +} diff --git a/manifests/upstream.json b/manifests/upstream.json new file mode 100644 index 0000000..baf7342 --- /dev/null +++ b/manifests/upstream.json @@ -0,0 +1,9 @@ +{ + "repository": "anomalyco/opencode", + "release_tag": "v1.18.3", + "version": "1.18.3", + "published_at": "2026-07-16T15:34:33Z", + "release_url": "https://github.com/anomalyco/opencode/releases/tag/v1.18.3", + "image": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e", + "observed_at": "2026-07-18T00:00:00Z" +} diff --git a/profiles/plugin-ledger.json b/profiles/plugin-ledger.json new file mode 100644 index 0000000..2fde50e --- /dev/null +++ b/profiles/plugin-ledger.json @@ -0,0 +1,11 @@ +{ + "id": "plugin-ledger", + "owner": "opencode-ajent", + "required_behaviors": [ + "exact-upstream-image", + "exact-plugin-version", + "runtime-launcher-separate-from-plugin", + "ledger-hooks", + "permission-hooks" + ] +} diff --git a/profiles/provider-hooks.json b/profiles/provider-hooks.json new file mode 100644 index 0000000..99cfacc --- /dev/null +++ b/profiles/provider-hooks.json @@ -0,0 +1,11 @@ +{ + "id": "provider-hooks", + "owner": "inference", + "required_behaviors": [ + "provider-schema", + "model-routing", + "system-prompt-hook", + "session-migration-dry-run", + "session-migration-idempotency" + ] +} diff --git a/profiles/rails-persisted-turn.json b/profiles/rails-persisted-turn.json new file mode 100644 index 0000000..f29f755 --- /dev/null +++ b/profiles/rails-persisted-turn.json @@ -0,0 +1,13 @@ +{ + "id": "rails-persisted-turn", + "owner": "opencode-rails", + "extends": ["ruby-rest-sse"], + "required_behaviors": [ + "session-recovery", + "mid-stream-snapshot", + "terminal-state-cas", + "authoritative-final-exchange", + "aggregate-turn-usage", + "artifact-reconciliation" + ] +} diff --git a/profiles/ruby-rest-sse.json b/profiles/ruby-rest-sse.json new file mode 100644 index 0000000..33e67d3 --- /dev/null +++ b/profiles/ruby-rest-sse.json @@ -0,0 +1,19 @@ +{ + "id": "ruby-rest-sse", + "owner": "opencode-ruby", + "required_fixtures": [ + "legacy-idle", + "current-status-idle", + "delta-before-part", + "reasoning-and-final", + "multi-step-usage", + "interactive-prompts", + "unknown-event" + ], + "transport_scenarios": [ + "subscribe-ready-before-prompt", + "reconnect-without-prompt-replay", + "prompt-transport-error-is-visible" + ], + "live_contract": ["health", "create-session", "async-prompt", "sse-terminal", "final-exchange", "delete-session"] +} diff --git a/profiles/strict-v2.json b/profiles/strict-v2.json new file mode 100644 index 0000000..9f5a5ef --- /dev/null +++ b/profiles/strict-v2.json @@ -0,0 +1,11 @@ +{ + "id": "strict-v2", + "owner": "leela", + "required_behaviors": [ + "exact-upstream-version-and-commit", + "bun-toolchain-provenance", + "v2-route-contract", + "every-mutating-route-requires-owner", + "admission-and-abort-remain-custom" + ] +} diff --git a/profiles/voice-stream.json b/profiles/voice-stream.json new file mode 100644 index 0000000..334e8f6 --- /dev/null +++ b/profiles/voice-stream.json @@ -0,0 +1,11 @@ +{ + "id": "voice-stream", + "owner": "greenroom", + "extends": ["ruby-rest-sse"], + "required_behaviors": [ + "legacy-and-current-terminal-events", + "aggregate-assistant-usage", + "authoritative-final-text", + "reconnect-same-stream-without-reprompt" + ] +} diff --git a/ruby/live_probe.rb b/ruby/live_probe.rb new file mode 100644 index 0000000..b13c1cb --- /dev/null +++ b/ruby/live_probe.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "json" +require "time" + +gem_path = File.expand_path(ENV.fetch("OPENCODE_RUBY_PATH")) +$LOAD_PATH.unshift(File.join(gem_path, "lib")) +require "opencode-ruby" + +base_url = ENV.fetch("OPENCODE_BASE_URL") +model = ENV.fetch("OPENCODE_COMPAT_MODEL", "compat/compat-model") +expected_text = ENV.fetch("OPENCODE_COMPAT_EXPECTED_TEXT", "compat-ok") +client = Opencode::Client.new(base_url: base_url, timeout: 30) +session_id = nil + +begin + health = client.health + raise "OpenCode health did not report healthy: #{health.inspect}" unless health[:healthy] || health[:ok] + + session = client.create_session(title: "opencode-compat isolated probe") + session_id = session.fetch(:id) + observed_parts = [] + + result = client.stream( + session_id, + "Reply with exactly: #{expected_text}", + model: model, + stream_timeout: 30, + first_event_timeout: 15, + idle_stream_timeout: 15 + ) do |part| + observed_parts << JSON.parse(JSON.generate(part)) + end + + unless result.full_text.include?(expected_text) + raise "Expected final text to include #{expected_text.inspect}, got #{result.full_text.inspect}" + end + + messages = client.get_messages(session_id) + assistant_messages = Array(messages).select { |message| message.dig(:info, :role) == "assistant" } + raise "No authoritative assistant exchange returned" if assistant_messages.empty? + + puts JSON.generate( + status: "pass", + checked_at: Time.now.utc.iso8601, + opencode_base_url: base_url, + opencode_health: health, + opencode_ruby_version: Opencode::VERSION, + opencode_ruby_commit: ENV["OPENCODE_RUBY_COMMIT"], + session_id: session_id, + expected_text: expected_text, + full_text: result.full_text, + observed_part_count: observed_parts.length, + authoritative_assistant_message_count: assistant_messages.length + ) +rescue StandardError => error + diagnostic = {error: "#{error.class}: #{error.message}"} + if session_id + diagnostic[:session_status] = client.session_status + diagnostic[:messages] = client.get_messages(session_id) + end + warn JSON.generate(diagnostic) + raise +ensure + client.delete_session(session_id) if session_id +end diff --git a/ruby/opencode_ruby_fixture_contract.rb b/ruby/opencode_ruby_fixture_contract.rb new file mode 100644 index 0000000..1bf8a51 --- /dev/null +++ b/ruby/opencode_ruby_fixture_contract.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "json" + +root = File.expand_path("..", __dir__) +gem_path = File.expand_path(ENV.fetch("OPENCODE_RUBY_PATH")) +$LOAD_PATH.unshift(File.join(gem_path, "lib")) +require "opencode-ruby" + +manifest = JSON.parse(File.read(File.join(root, "fixtures/manifest.json"))) +failures = [] + +manifest.fetch("fixtures").each do |entry| + reply = Opencode::Reply.new + event_path = File.join(root, "fixtures", entry.fetch("events")) + expected_path = File.join(root, "fixtures", entry.fetch("expected")) + + File.foreach(event_path) do |line| + next if line.strip.empty? + + reply.apply(JSON.parse(line, symbolize_names: true)) + end + + result = reply.result + expected = JSON.parse(File.read(expected_path)) + actual = { + "full_text" => result.full_text, + "reasoning_text" => result.reasoning_text, + "tool_count" => result.tool_parts.length, + "prompt_blocked" => reply.prompt_blocked?, + "total_cost" => reply.total_cost, + "total_input_tokens" => reply.total_input_tokens, + "total_output_tokens" => reply.total_output_tokens + } + + if (tool_expectation = expected["tool"]) + tool = result.tool_parts.first || reply.parts.find { |part| part["type"] == "tool" } + actual["tool"] = { + "tool" => tool&.fetch("tool", nil), + "status" => tool&.fetch("status", nil), + "callID" => tool&.fetch("callID", nil), + "opencode_request_id" => tool&.dig("input", "opencode_request_id") + } + expected["tool"] = tool_expectation + end + + expected.each do |key, value| + next if actual[key] == value + + failures << "#{entry.fetch("id")}: #{key} expected #{value.inspect}, got #{actual[key].inspect}" + end + + unless failures.any? { |failure| failure.start_with?("#{entry.fetch("id")}:") } + puts "PASS #{entry.fetch("id")}" + end +end + +unless failures.empty? + warn failures.join("\n") + exit 1 +end + +puts JSON.generate( + status: "pass", + adapter: "opencode-ruby", + adapter_version: Opencode::VERSION, + adapter_commit: ENV["OPENCODE_RUBY_COMMIT"], + fixture_count: manifest.fetch("fixtures").length +) diff --git a/scripts/fake_llm.py b/scripts/fake_llm.py new file mode 100755 index 0000000..b674699 --- /dev/null +++ b/scripts/fake_llm.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Deterministic OpenAI-compatible streaming server for image contracts.""" + +from __future__ import annotations + +import argparse +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +class State: + def __init__(self) -> None: + self.lock = threading.Lock() + self.requests: list[dict[str, object]] = [] + + def record(self, path: str, body: object) -> None: + with self.lock: + self.requests.append({"path": path, "body": body, "at": time.time()}) + + def snapshot(self) -> dict[str, object]: + with self.lock: + return {"request_count": len(self.requests), "requests": list(self.requests)} + + +STATE = State() + + +def chat_stream(text: str) -> bytes: + chunks = [ + { + "id": "chatcmpl-opencode-compat", + "object": "chat.completion.chunk", + "choices": [{"delta": {"role": "assistant"}}], + }, + { + "id": "chatcmpl-opencode-compat", + "object": "chat.completion.chunk", + "choices": [{"delta": {"content": text}}], + }, + { + "id": "chatcmpl-opencode-compat", + "object": "chat.completion.chunk", + "choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, + }, + ] + payload = "".join(f"data: {json.dumps(chunk, separators=(',', ':'))}\n\n" for chunk in chunks) + payload += "data: [DONE]\n\n" + return payload.encode() + + +class Handler(BaseHTTPRequestHandler): + server_version = "OpenCodeCompatLLM/1" + + def log_message(self, _format: str, *_args: object) -> None: + return + + def send_bytes(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/health": + self.send_bytes(200, b'{"ok":true}', "application/json") + return + if self.path == "/stats": + self.send_bytes(200, json.dumps(STATE.snapshot()).encode(), "application/json") + return + if self.path == "/v1/models": + body = {"object": "list", "data": [{"id": "compat-model", "object": "model"}]} + self.send_bytes(200, json.dumps(body).encode(), "application/json") + return + self.send_bytes(404, b'{"error":"not found"}', "application/json") + + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) if length else b"{}" + try: + body = json.loads(raw) + except json.JSONDecodeError: + body = {"invalid_json": raw.decode(errors="replace")} + STATE.record(self.path, body) + + if self.path == "/v1/chat/completions": + self.send_bytes(200, chat_stream("compat-ok"), "text/event-stream") + return + + self.send_bytes(404, b'{"error":"unsupported endpoint"}', "application/json") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--port-file", required=True) + args = parser.parse_args() + + server = ThreadingHTTPServer((args.host, args.port), Handler) + Path(args.port_file).write_text(str(server.server_port), encoding="utf-8") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/matrix_json.rb b/scripts/matrix_json.rb new file mode 100755 index 0000000..6ac0e37 --- /dev/null +++ b/scripts/matrix_json.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "json" + +root = File.expand_path("..", __dir__) +manifest = JSON.parse(File.read(File.join(root, "manifests/image-matrix.json"))) +matrix = manifest.fetch("public_ci").map do |target| + { + "id" => target.fetch("id"), + "version" => target.fetch("version"), + "image" => target.fetch("image") + } +end + +puts JSON.generate("include" => matrix) diff --git a/scripts/record_upstream_candidate.rb b/scripts/record_upstream_candidate.rb new file mode 100755 index 0000000..59b089e --- /dev/null +++ b/scripts/record_upstream_candidate.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "json" +require "time" + +root = File.expand_path("..", __dir__) +tag, published_at, release_url, digest = ARGV +abort "usage: record_upstream_candidate.rb TAG PUBLISHED_AT RELEASE_URL DIGEST" unless digest +abort "invalid upstream release tag: #{tag.inspect}" unless tag.match?(/\Av\d+\.\d+\.\d+\z/) +abort "invalid OCI digest: #{digest.inspect}" unless digest.match?(/\Asha256:[0-9a-f]{64}\z/) + +version = tag.delete_prefix("v") +image = "ghcr.io/anomalyco/opencode@#{digest}" +upstream_path = File.join(root, "manifests/upstream.json") +matrix_path = File.join(root, "manifests/image-matrix.json") +upstream = JSON.parse(File.read(upstream_path)) +matrix = JSON.parse(File.read(matrix_path)) + +exit 0 if upstream.fetch("release_tag") == tag + +upstream.merge!( + "release_tag" => tag, + "version" => version, + "published_at" => published_at, + "release_url" => release_url, + "image" => image, + "observed_at" => Time.now.utc.iso8601 +) + +unless matrix.fetch("public_ci").any? { |target| target.fetch("image") == image } + matrix.fetch("public_ci") << { + "id" => "upstream-#{version}", + "version" => version, + "image" => image, + "tag_provenance" => "ghcr.io/anomalyco/opencode:#{version}", + "consumers" => ["upstream-candidate"], + "profiles" => ["ruby-rest-sse", "rails-persisted-turn", "voice-stream", "strict-v2", "plugin-ledger", "provider-hooks"], + "certification_status" => "pending" + } +end + +File.write(upstream_path, JSON.pretty_generate(upstream) + "\n") +File.write(matrix_path, JSON.pretty_generate(matrix) + "\n") diff --git a/scripts/run_image_contract.sh b/scripts/run_image_contract.sh new file mode 100755 index 0000000..b876abb --- /dev/null +++ b/scripts/run_image_contract.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +image="${OPENCODE_IMAGE:?set OPENCODE_IMAGE to an immutable image digest}" +gem_path="${OPENCODE_RUBY_PATH:?set OPENCODE_RUBY_PATH to the candidate checkout}" + +if [[ "$image" != *@sha256:* ]]; then + if [[ "${ALLOW_EXACT_IMAGE_ID:-0}" != "1" || "$image" != sha256:* ]]; then + echo "OPENCODE_IMAGE must be an OCI digest (or an exact local image ID with ALLOW_EXACT_IMAGE_ID=1)" >&2 + exit 2 + fi +fi + +container_name="opencode-compat-${RANDOM}-$$" +llm_container_name="opencode-compat-llm-${RANDOM}-$$" +network_name="opencode-compat-net-${RANDOM}-$$" +python_image="python@sha256:399babc8b49529dabfd9c922f2b5eea81d611e4512e3ed250d75bd2e7683f4b0" + +cleanup() { + status=$? + if [[ "$status" != "0" ]]; then + echo "OpenCode compatibility probe failed; container log follows" >&2 + docker logs "$container_name" >&2 2>/dev/null || true + echo "Deterministic model request summary follows" >&2 + docker exec "$llm_container_name" wget -qO- http://127.0.0.1:8080/stats >&2 2>/dev/null || true + echo >&2 + fi + docker rm -f "$container_name" >/dev/null 2>&1 || true + docker rm -f "$llm_container_name" >/dev/null 2>&1 || true + docker network rm "$network_name" >/dev/null 2>&1 || true + return "$status" +} +trap cleanup EXIT + +docker network create "$network_name" >/dev/null +docker run --detach \ + --name "$llm_container_name" \ + --network "$network_name" \ + --network-alias compat-llm \ + --volume "$repo_root/scripts:/compat:ro" \ + "$python_image" \ + python /compat/fake_llm.py --port 8080 --port-file /tmp/compat-port \ + >/dev/null + +for _ in $(seq 1 100); do + if docker exec "$llm_container_name" wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1; then + llm_ready=1 + break + fi + sleep 0.05 +done +[[ "${llm_ready:-0}" == "1" ]] || { echo "fake LLM did not start" >&2; exit 1; } + +config_json="$(jq -cn --arg url "http://compat-llm:8080/v1" '{ + "$schema": "https://opencode.ai/config.json", + formatter: false, + lsp: false, + provider: { + compat: { + name: "Compatibility fixture", + id: "compat", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "compat-model": { + id: "compat-model", + name: "Compatibility model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2026-01-01", + limit: {context: 100000, output: 10000}, + cost: {input: 0, output: 0}, + options: {} + } + }, + options: {apiKey: "compat-key", baseURL: $url} + } + } +}')" + +docker run --detach \ + --name "$container_name" \ + --network "$network_name" \ + --publish 127.0.0.1::4096 \ + --env "OPENCODE_CONFIG_CONTENT=$config_json" \ + --env OPENCODE_DISABLE_AUTOUPDATE=1 \ + --env OPENCODE_DISABLE_AUTOCOMPACT=1 \ + --env OPENCODE_DISABLE_MODELS_FETCH=1 \ + --env OPENCODE_DISABLE_PROJECT_CONFIG=1 \ + --env OPENCODE_PURE=1 \ + "$image" serve --hostname 0.0.0.0 --port 4096 \ + >/dev/null + +host_port="$(docker port "$container_name" 4096/tcp | sed -E 's/.*:([0-9]+)$/\1/' | head -1)" +base_url="http://127.0.0.1:${host_port}" + +ready=0 +for _ in $(seq 1 120); do + if curl --fail --silent --connect-timeout 1 --max-time 2 "$base_url/global/health" >/dev/null; then + ready=1 + break + fi + if ! docker inspect "$container_name" --format '{{.State.Running}}' 2>/dev/null | grep -qx true; then + docker logs "$container_name" >&2 || true + exit 1 + fi + sleep 0.25 +done + +if [[ "$ready" != "1" ]]; then + docker logs "$container_name" >&2 || true + echo "OpenCode did not become ready" >&2 + exit 1 +fi + +server_version="$(docker exec "$container_name" opencode --version)" +image_id="$(docker inspect "$container_name" --format '{{.Image}}')" +gem_commit="$(git -C "$gem_path" rev-parse HEAD 2>/dev/null || true)" + +OPENCODE_BASE_URL="$base_url" \ +OPENCODE_RUBY_PATH="$gem_path" \ +OPENCODE_RUBY_COMMIT="$gem_commit" \ + ruby "$repo_root/ruby/live_probe.rb" + +llm_stats="$(docker exec "$llm_container_name" wget -qO- http://127.0.0.1:8080/stats)" +request_count="$(jq -r '.request_count' <<<"$llm_stats")" +if [[ "$request_count" -lt 1 ]]; then + echo "OpenCode never called the deterministic model" >&2 + exit 1 +fi + +jq -cn \ + --arg status pass \ + --arg image "$image" \ + --arg image_id "$image_id" \ + --arg server_version "$server_version" \ + --arg gem_commit "$gem_commit" \ + --argjson llm_request_count "$request_count" \ + '{status:$status,image:$image,image_id:$image_id,server_version:$server_version,opencode_ruby_commit:$gem_commit,llm_request_count:$llm_request_count}' diff --git a/test/repository_test.rb b/test/repository_test.rb new file mode 100644 index 0000000..22dfcfe --- /dev/null +++ b/test/repository_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" + +class RepositoryTest < Minitest::Test + ROOT = File.expand_path("..", __dir__) + + def json(path) + JSON.parse(File.read(File.join(ROOT, path))) + end + + def test_every_json_document_parses + paths = Dir.glob(File.join(ROOT, "{fixtures,manifests,profiles}/**/*.json")) + refute_empty paths + paths.each { |path| JSON.parse(File.read(path)) } + end + + def test_fixture_manifest_is_complete_and_unique + entries = json("fixtures/manifest.json").fetch("fixtures") + ids = entries.map { |entry| entry.fetch("id") } + assert_equal ids.uniq, ids + + entries.each do |entry| + assert_path_exists File.join(ROOT, "fixtures", entry.fetch("events")) + assert_path_exists File.join(ROOT, "fixtures", entry.fetch("expected")) + end + end + + def test_profile_fixture_references_exist + fixture_ids = json("fixtures/manifest.json").fetch("fixtures").map { |entry| entry.fetch("id") } + Dir.glob(File.join(ROOT, "profiles/*.json")).each do |path| + profile = JSON.parse(File.read(path)) + Array(profile["required_fixtures"]).each { |id| assert_includes fixture_ids, id } + end + end + + def test_public_matrix_uses_only_immutable_oci_digests + targets = json("manifests/image-matrix.json").fetch("public_ci") + refute_empty targets + targets.each do |target| + assert_match %r{\Aghcr\.io/anomalyco/opencode@sha256:[0-9a-f]{64}\z}, target.fetch("image") + refute_includes target.fetch("image"), ":latest" + refute_empty target.fetch("profiles") + end + end + + def test_certified_migration_keeps_previous_tuple + tuples = json("manifests/runtime-tuples.json") + return unless tuples.fetch("migration_state") == "certified" + + tuples.fetch("consumers").each_value do |consumer| + refute_nil consumer["current"] + refute_nil consumer["previous"] + assert_equal "certified", consumer.fetch("current").fetch("status") + end + end + + def test_watcher_has_no_deployment_commands + workflow = File.read(File.join(ROOT, ".github/workflows/watch-upstream.yml")) + refute_match(/\b(kamal|kubectl|helm|nomad|docker\s+service)\b/i, workflow) + end +end