10 Commits

10 changed files with 361 additions and 24 deletions

View File

@@ -7,17 +7,26 @@ on:
jobs: jobs:
push: push:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
id-token: write id-token: write
environment: release environment: release
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with: with:
ruby-version: ruby ruby-version: "4.0"
bundler-cache: true bundler-cache: true
- uses: rubygems/release-gem@v1 - name: Verify tag matches gem version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: >-
ruby -Ilib -ropencode/version -e
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
abort "tag #{expected.inspect} does not match gem #{Opencode::VERSION.inspect}"
unless Opencode::VERSION == expected'
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0

View File

@@ -12,12 +12,12 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
ruby: ["3.2", "3.3", "3.4"] ruby: ["3.2", "3.3", "3.4", "4.0"]
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Ruby ${{ matrix.ruby }} - name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with: with:
ruby-version: ${{ matrix.ruby }} ruby-version: ${{ matrix.ruby }}
bundler-cache: true bundler-cache: true

View File

@@ -1,5 +1,21 @@
# Changelog # Changelog
## 0.0.1.alpha8 - 2026-07-20
### Fixed
- Parse SSE events with CRLF, CR-only, or LF framing; accept `data:` fields
with or without the optional leading space; join multiple `data:` fields;
ignore the stream's optional leading UTF-8 BOM and comment fields; and decode
correctly when a transport chunk splits any byte of the event framing.
### Changed
- Test Ruby 3.2, 3.3, 3.4, and 4.0; pin every third-party CI and release
action to an exact reviewed commit; and use Ruby 4.0 for release builds.
- Fail the trusted-publishing job before release when the pushed tag does not
match `Opencode::VERSION`.
## 0.0.1.alpha7 - 2026-07-18 ## 0.0.1.alpha7 - 2026-07-18
### Fixed ### Fixed
@@ -100,5 +116,7 @@ First public alpha. HTTP + SSE client for OpenCode REST API.
### Compatibility ### Compatibility
- Ruby ≥ 3.2 - Ruby ≥ 3.2
- OpenCode server ≥ 1.15 (tested against the message bus schema in `packages/opencode/src/session/message-v2.ts`) - OpenCode targeted the then-current 1.15 message-bus shape. This historical
target was not a blanket SemVer compatibility guarantee; use the README's
current compatibility evidence for deployment decisions.
- Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem. - Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem.

View File

@@ -67,7 +67,9 @@ child = client.create_session(
Model strings use OpenCode's `provider/model` form; a preformatted model hash Model strings use OpenCode's `provider/model` form; a preformatted model hash
with `providerID` and `id` keys is also accepted. These configured-session with `providerID` and `id` keys is also accepted. These configured-session
fields require OpenCode 1.16.1 or newer. fields first appeared in OpenCode 1.16.1. That is an API-introduction note,
not evidence that every later server is compatible; use the
[certified compatibility evidence](#compatibility) for deployment choices.
### Streaming (the headline) ### Streaming (the headline)
@@ -106,8 +108,9 @@ client.update_session(session_id, permissions: permission_rules)
OpenCode appends PATCHed permission rules and evaluates the last matching OpenCode appends PATCHed permission rules and evaluates the last matching
rule. Hosts should send a complete ordered policy and fingerprint it so the rule. Hosts should send a complete ordered policy and fingerprint it so the
same policy is not appended on every turn. This endpoint requires OpenCode same policy is not appended on every turn. This endpoint first appeared in
1.16.1 or newer; the rest of the client remains compatible with 1.15. OpenCode 1.16.1; that version floor describes endpoint availability, not
compatibility with every later server.
### Lower-level event firehose ### Lower-level event firehose
@@ -199,9 +202,29 @@ Want every OpenCode endpoint auto-generated from the OpenAPI spec? Use [`opencod
## Compatibility ## Compatibility
- Ruby ≥ 3.2 - Ruby ≥ 3.2
- OpenCode server ≥ 1.15
- Runtime dependency: `activesupport (>= 6.1)`*not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.). - Runtime dependency: `activesupport (>= 6.1)`*not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.).
OpenCode server compatibility is evidence-based, not an open-ended SemVer
promise. OpenCode's HTTP, SSE, and runtime behavior can change independently
of a numeric version floor, so this gem does not infer compatibility from a
constraint such as `>= 1.15`.
Use the [OpenCode compatibility corpus](https://github.com/ajaynomics/opencode-compat)
as the source of current evidence. Its
[image matrix](https://github.com/ajaynomics/opencode-compat/blob/main/manifests/image-matrix.json)
records the exact gem commit and immutable OpenCode OCI digest exercised by
the `ruby-rest-sse` profile. Its
[runtime tuples](https://github.com/ajaynomics/opencode-compat/blob/main/manifests/runtime-tuples.json)
add the exact consumer commit and canary evidence needed for promotion and
rollback. A version label or image tag is provenance only, never the execution
coordinate.
A combination absent from passing evidence is **unverified**, not necessarily
incompatible. Run the corpus against the exact gem, image digest, and consumer
commit before adopting or promoting it. See the
[certification policy](https://github.com/ajaynomics/opencode-compat/blob/main/docs/certification.md)
for the full process.
## Development ## Development
```sh ```sh
@@ -213,10 +236,13 @@ The smoke suite covers Client end-to-end against WebMock-stubbed OpenCode
endpoints, including subscription-before-prompt ordering and endpoints, including subscription-before-prompt ordering and
reconnect-without-repost. reconnect-without-repost.
Releases use RubyGems trusted publishing. After the repository's The repository contains a tag-triggered `release.yml` workflow intended for
`release.yml` workflow is registered as a trusted publisher with the `release` RubyGems trusted publishing, but its RubyGems trusted-publisher registration is
environment, pushing a `v*` tag builds, attests, and publishes the gem without not configured as of `0.0.1.alpha7`; that release was published manually. A
a long-lived RubyGems API key. `v*` tag push therefore does not currently guarantee publication. Before a
future release, verify the RubyGems registry result explicitly. Once the
workflow is registered as a trusted publisher for the `release` environment,
it can build, attest, and publish without a long-lived RubyGems API key.
## License ## License

View File

@@ -255,6 +255,10 @@ module Opencode
end end
MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses
# Keep CRLF atomic so it cannot backtrack into CR + LF and look like a
# blank line. SSE also permits bare CR and LF line endings.
SSE_EVENT_BOUNDARY = /(?>\r\n|[\r\n]){2}/
UTF8_BOM = "\xEF\xBB\xBF".b.freeze
SSE_RECONNECT_DELAY = 0.1 SSE_RECONNECT_DELAY = 0.1
TRANSIENT_SSE_ERRORS = [ TRANSIENT_SSE_ERRORS = [
EOFError, EOFError,
@@ -360,6 +364,7 @@ module Opencode
subscription_ready = on_subscribed.nil? subscription_ready = on_subscribed.nil?
begin begin
buffer = String.new buffer = String.new
first_sse_event = true
http.request(request) do |response| http.request(request) do |response|
unless response.is_a?(Net::HTTPSuccess) unless response.is_a?(Net::HTTPSuccess)
@@ -386,9 +391,14 @@ module Opencode
raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes" raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes"
end end
while (idx = buffer.index("\n\n")) while (boundary = buffer.match(SSE_EVENT_BOUNDARY))
raw_event = buffer.slice!(0, idx + 2) raw_event = buffer.slice!(0, boundary.end(0))
event = parse_sse_event(raw_event, session_id) event = parse_sse_event(
raw_event,
session_id,
allow_bom: first_sse_event
)
first_sse_event = false
next unless event next unless event
unless subscription_ready unless subscription_ready
@@ -639,11 +649,22 @@ module Opencode
end end
end end
def parse_sse_event(raw, session_id) def parse_sse_event(raw, session_id, allow_bom: false)
data_line = raw.lines.find { |l| l.start_with?("data: ") } if allow_bom && raw.b.start_with?(UTF8_BOM)
return nil unless data_line raw = raw.byteslice(UTF8_BOM.bytesize..)
end
json = JSON.parse(data_line.sub("data: ", "").strip, symbolize_names: true) data = raw.split(/\r\n|\r|\n/, -1).filter_map do |line|
next if line.start_with?(":")
field, value = line.split(":", 2)
next unless field == "data"
(value || "").delete_prefix(" ")
end
return nil if data.empty?
json = JSON.parse(data.join("\n"), symbolize_names: true)
event_session = json.dig(:properties, :sessionID) || event_session = json.dig(:properties, :sessionID) ||
json.dig(:properties, :info, :sessionID) || json.dig(:properties, :info, :sessionID) ||

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Opencode module Opencode
VERSION = "0.0.1.alpha7" VERSION = "0.0.1.alpha8"
end end

View File

@@ -126,6 +126,57 @@ class SmokeTest < Minitest::Test
assert_equal({}, response) assert_equal({}, response)
end end
def test_send_message_async_serializes_a_structured_child_prompt
schema = {
type: "object",
properties: {
requirement_suggestions: {
type: "array",
items: { type: "string" }
}
},
required: [ "requirement_suggestions" ],
additionalProperties: false
}
invocation_query =
"Complete the bounded structured request from the preloaded worker skill and immutable Rails context."
expected_body = {
messageID: "msg_worker_1",
parts: [ { type: "text", text: invocation_query } ],
agent: "destination-list-curator",
format: {
type: "json_schema",
schema: schema,
retryCount: 0
},
system: "Immutable Rails context"
}
serialized_body = nil
prompt = stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async")
.with(body: expected_body.to_json)
.to_return do |request|
serialized_body = request.body
{ status: 204, body: "" }
end
@client.send_message_async(
SESSION_ID,
invocation_query,
agent: "destination-list-curator",
system: "Immutable Rails context",
message_id: "msg_worker_1",
format: {
type: "json_schema",
schema: schema,
retryCount: 0
}
)
assert_equal expected_body.to_json, serialized_body
assert_requested prompt, times: 1
end
def test_stream_returns_typed_Reply_Result_with_full_text def test_stream_returns_typed_Reply_Result_with_full_text
stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async") stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async")
.to_return(status: 204, body: "") .to_return(status: 204, body: "")
@@ -335,6 +386,79 @@ class SmokeTest < Minitest::Test
assert_requested event_stream, times: 1 assert_requested event_stream, times: 1
end end
def test_stream_events_accepts_standard_sse_framing_variants
terminal_event = {
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
}
connected_data = JSON.pretty_generate(CONNECTED_EVENT).lines(chomp: true)
.map { |line| "data: #{line}\r\n" }
.join
body = ": readiness comment\r\nevent: message\r\n#{connected_data}\r\n" \
"data:#{terminal_event.to_json}\r\r"
event_stream = stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(status: 200, body: body,
headers: { "Content-Type" => "text/event-stream" })
subscribed_calls = 0
event_types = []
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
subscribed_calls += 1
true
}
) { |event| event_types << event.fetch(:type) }
assert_equal 1, subscribed_calls
assert_equal [ "server.connected", "session.status" ], event_types
assert_requested event_stream, times: 1
end
def test_stream_events_handles_a_bom_and_boundaries_split_across_chunks
terminal_event = {
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
}
body = "\uFEFF: initial comment\r\n\r\n" \
"data: #{CONNECTED_EVENT.to_json}\r\n\r\n" \
"data: #{terminal_event.to_json}\n\n"
chunks = body.b.bytes.map { |byte| byte.chr(Encoding::BINARY) }
response = Net::HTTPOK.new("1.1", "200", "OK")
response.define_singleton_method(:read_body) do |&callback|
chunks.each(&callback)
end
http = Object.new
http.define_singleton_method(:use_ssl=) { |_value| }
http.define_singleton_method(:open_timeout=) { |_value| }
http.define_singleton_method(:read_timeout=) { |_value| }
http.define_singleton_method(:request) do |_request, &callback|
callback.call(response)
end
http.define_singleton_method(:started?) { false }
subscribed_calls = 0
event_types = []
Net::HTTP.stub(:new, ->(_host, _port) { http }) do
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
subscribed_calls += 1
true
}
) { |event| event_types << event.fetch(:type) }
end
assert_equal 1, subscribed_calls
assert_equal [ "server.connected", "session.status" ], event_types
end
def test_stream_events_preserves_question_and_permission_wait_state def test_stream_events_preserves_question_and_permission_wait_state
events = [ events = [
{ {

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
require "test_helper"
class ReadmeContractTest < Minitest::Test
README = File.read(File.expand_path("../README.md", __dir__))
def test_server_compatibility_points_to_exact_certification_evidence
assert_includes README, "https://github.com/ajaynomics/opencode-compat"
assert_includes README, "manifests/image-matrix.json"
assert_includes README, "manifests/runtime-tuples.json"
refute_match(/OpenCode server\s*(?:>=|≥)\s*\d/, README)
end
def test_release_guidance_does_not_claim_trusted_publishing_is_configured
assert_includes README, "not configured as of `0.0.1.alpha7`"
assert_includes README, "does not currently guarantee publication"
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
require "minitest/autorun"
require "yaml"
class ReleaseWorkflowTest < Minitest::Test
ROOT = File.expand_path("..", __dir__)
WORKFLOW_PATH = File.join(ROOT, ".github", "workflows", "release.yml")
SETUP_RUBY_ACTION = "ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600"
RELEASE_GEM_ACTION = "rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092"
def workflow
@workflow ||= YAML.safe_load(File.read(WORKFLOW_PATH), aliases: false)
end
def push_job
workflow.fetch("jobs").fetch("push")
end
def test_release_job_is_inert_on_non_github_runners
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
end
def test_release_job_keeps_the_trusted_publisher_boundary
assert_equal "release", push_job.fetch("environment")
assert_equal(
{ "contents" => "write", "id-token" => "write" },
push_job.fetch("permissions")
)
steps = push_job.fetch("steps")
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
assert_equal "4.0", setup_ruby.dig("with", "ruby-version")
assert_equal 1, steps.count { |step| step["uses"] == RELEASE_GEM_ACTION }
assert steps.filter_map { |step| step["uses"] }.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) }
refute steps.any? { |step| step.fetch("run", "").match?(/\bgem\s+push\b/) }
end
def test_release_tag_must_match_the_gem_version_before_publish
steps = push_job.fetch("steps")
preflight_index = steps.index { |step| step["name"] == "Verify tag matches gem version" }
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
refute_nil preflight_index
refute_nil publish_index
assert_operator preflight_index, :<, publish_index
preflight = steps.fetch(preflight_index)
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
assert_includes preflight.fetch("run"), "unless Opencode::VERSION == expected"
end
end

View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
require "minitest/autorun"
require "yaml"
class WorkflowContractTest < Minitest::Test
ROOT = File.expand_path("..", __dir__)
WORKFLOW_DIRECTORY = File.join(ROOT, ".github", "workflows")
TEST_WORKFLOW_PATH = File.join(WORKFLOW_DIRECTORY, "test.yml")
ACTION_PINS = {
"actions/checkout" => "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
"ruby/setup-ruby" => "003a5c4d8d6321bd302e38f6f0ec593f77f06600",
"rubygems/release-gem" => "052cc82692552de3ef2b81fd670e41d13cba8092"
}.freeze
def test_matrix_covers_every_supported_ruby
workflow = YAML.safe_load(File.read(TEST_WORKFLOW_PATH), aliases: false)
versions = workflow.dig("jobs", "test", "strategy", "matrix", "ruby")
assert_equal %w[3.2 3.3 3.4 4.0], versions
end
def test_every_third_party_action_uses_its_reviewed_commit
action_uses = Dir[File.join(WORKFLOW_DIRECTORY, "*.{yml,yaml}")].sort.flat_map do |path|
workflow = YAML.safe_load(File.read(path), aliases: false)
workflow_uses(workflow)
end
assert_equal 5, action_uses.length
action_uses.each do |action_use|
action, separator, revision = action_use.rpartition("@")
assert_equal "@", separator
assert_equal ACTION_PINS.fetch(action), revision
assert_match(/\A[0-9a-f]{40}\z/, revision)
end
end
def test_action_discovery_only_reads_workflow_action_locations
workflow = YAML.safe_load(<<~YAML, aliases: false)
jobs:
reusable:
uses: "owner/workflow@revision"
with:
uses: ordinary-job-input
test:
steps:
- uses: "owner/action@revision"
with:
uses: ordinary-step-input
YAML
assert_equal %w[owner/workflow@revision owner/action@revision], workflow_uses(workflow)
end
private
def workflow_uses(node)
node.fetch("jobs").values.flat_map do |job|
action_uses = job.key?("uses") ? [job.fetch("uses")] : []
step_uses = job.fetch("steps", []).filter_map { |step| step["uses"] }
action_uses.concat(step_uses)
end
end
end