Establish OpenCode compatibility certification corpus
Some checks failed
Some checks failed
This commit is contained in:
93
.github/workflows/candidate.yml
vendored
Normal file
93
.github/workflows/candidate.yml
vendored
Normal file
@@ -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
|
||||
81
.github/workflows/watch-upstream.yml
vendored
Normal file
81
.github/workflows/watch-upstream.yml
vendored
Normal file
@@ -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."
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/.bundle/
|
||||
/vendor/bundle/
|
||||
/*.log
|
||||
/.compat-evidence/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
69
README.md
Normal file
69
README.md
Normal file
@@ -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).
|
||||
49
docs/certification.md
Normal file
49
docs/certification.md
Normal file
@@ -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.
|
||||
67
evidence/2026-07-18-opencode-ruby-alpha6.json
Normal file
67
evidence/2026-07-18-opencode-ruby-alpha6.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
4
fixtures/events/current-status-idle.jsonl
Normal file
4
fixtures/events/current-status-idle.jsonl
Normal file
@@ -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"}}}
|
||||
3
fixtures/events/delta-before-part.jsonl
Normal file
3
fixtures/events/delta-before-part.jsonl
Normal file
@@ -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"}}}
|
||||
7
fixtures/events/interactive-prompts.jsonl
Normal file
7
fixtures/events/interactive-prompts.jsonl
Normal file
@@ -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"}}}
|
||||
2
fixtures/events/legacy-idle.jsonl
Normal file
2
fixtures/events/legacy-idle.jsonl
Normal file
@@ -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"}}
|
||||
5
fixtures/events/multi-step-usage.jsonl
Normal file
5
fixtures/events/multi-step-usage.jsonl
Normal file
@@ -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"}}}
|
||||
5
fixtures/events/reasoning-and-final.jsonl
Normal file
5
fixtures/events/reasoning-and-final.jsonl
Normal file
@@ -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"}}}
|
||||
3
fixtures/events/unknown-event.jsonl
Normal file
3
fixtures/events/unknown-event.jsonl
Normal file
@@ -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"}}}
|
||||
6
fixtures/expected/current-status-idle.json
Normal file
6
fixtures/expected/current-status-idle.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"full_text": "current ok",
|
||||
"reasoning_text": "",
|
||||
"tool_count": 0,
|
||||
"prompt_blocked": false
|
||||
}
|
||||
6
fixtures/expected/delta-before-part.json
Normal file
6
fixtures/expected/delta-before-part.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"full_text": "tail drop recovered",
|
||||
"reasoning_text": "",
|
||||
"tool_count": 0,
|
||||
"prompt_blocked": false
|
||||
}
|
||||
12
fixtures/expected/interactive-prompts.json
Normal file
12
fixtures/expected/interactive-prompts.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
fixtures/expected/legacy-idle.json
Normal file
6
fixtures/expected/legacy-idle.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"full_text": "legacy ok",
|
||||
"reasoning_text": "",
|
||||
"tool_count": 0,
|
||||
"prompt_blocked": false
|
||||
}
|
||||
9
fixtures/expected/multi-step-usage.json
Normal file
9
fixtures/expected/multi-step-usage.json
Normal file
@@ -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
|
||||
}
|
||||
6
fixtures/expected/reasoning-and-final.json
Normal file
6
fixtures/expected/reasoning-and-final.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"full_text": "final answer",
|
||||
"reasoning_text": "considering carefully",
|
||||
"tool_count": 0,
|
||||
"prompt_blocked": false
|
||||
}
|
||||
6
fixtures/expected/unknown-event.json
Normal file
6
fixtures/expected/unknown-event.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"full_text": "still works",
|
||||
"reasoning_text": "",
|
||||
"tool_count": 0,
|
||||
"prompt_blocked": false
|
||||
}
|
||||
12
fixtures/manifest.json
Normal file
12
fixtures/manifest.json
Normal file
@@ -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"}
|
||||
]
|
||||
}
|
||||
6
manifests/client-candidate.json
Normal file
6
manifests/client-candidate.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"repository": "ajaynomics/opencode-ruby",
|
||||
"ref": "8adc95985a79b16e6e4bc0c0f827d638f244d509",
|
||||
"version": "0.0.1.alpha6",
|
||||
"status": "candidate"
|
||||
}
|
||||
58
manifests/image-matrix.json
Normal file
58
manifests/image-matrix.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
82
manifests/runtime-tuples.json
Normal file
82
manifests/runtime-tuples.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
9
manifests/upstream.json
Normal file
9
manifests/upstream.json
Normal file
@@ -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"
|
||||
}
|
||||
11
profiles/plugin-ledger.json
Normal file
11
profiles/plugin-ledger.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
11
profiles/provider-hooks.json
Normal file
11
profiles/provider-hooks.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
13
profiles/rails-persisted-turn.json
Normal file
13
profiles/rails-persisted-turn.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
19
profiles/ruby-rest-sse.json
Normal file
19
profiles/ruby-rest-sse.json
Normal file
@@ -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"]
|
||||
}
|
||||
11
profiles/strict-v2.json
Normal file
11
profiles/strict-v2.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
11
profiles/voice-stream.json
Normal file
11
profiles/voice-stream.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
66
ruby/live_probe.rb
Normal file
66
ruby/live_probe.rb
Normal file
@@ -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
|
||||
69
ruby/opencode_ruby_fixture_contract.rb
Normal file
69
ruby/opencode_ruby_fixture_contract.rb
Normal file
@@ -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
|
||||
)
|
||||
111
scripts/fake_llm.py
Executable file
111
scripts/fake_llm.py
Executable file
@@ -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()
|
||||
15
scripts/matrix_json.rb
Executable file
15
scripts/matrix_json.rb
Executable file
@@ -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)
|
||||
43
scripts/record_upstream_candidate.rb
Executable file
43
scripts/record_upstream_candidate.rb
Executable file
@@ -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")
|
||||
142
scripts/run_image_contract.sh
Executable file
142
scripts/run_image_contract.sh
Executable file
@@ -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}'
|
||||
63
test/repository_test.rb
Normal file
63
test/repository_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user