13 Commits

14 changed files with 736 additions and 59 deletions

View File

@@ -6,18 +6,69 @@ on:
- "v*" - "v*"
jobs: jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- 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'
- name: Run tests
run: bundle exec rake test
- name: Build gem
run: gem build opencode-ruby.gemspec
- name: Verify gem loads after install
run: |
bundle_gem_path="$(bundle exec ruby -e 'puts Gem.path.join(":")')"
export GEM_HOME="${RUNNER_TEMP}/opencode-ruby-${{ matrix.ruby }}"
export GEM_PATH="${GEM_HOME}:${bundle_gem_path}"
mkdir -p "$GEM_HOME"
gem_file="opencode-ruby-$(ruby -Ilib -ropencode/version -e 'print Opencode::VERSION').gem"
gem install --local "$gem_file" --no-document
ruby -ropencode-ruby -e '
root = File.realpath(ENV.fetch("GEM_HOME"))
path = File.realpath(Gem.loaded_specs.fetch("opencode-ruby").full_gem_path)
abort "opencode-ruby loaded outside isolated GEM_HOME: #{path}" unless path.start_with?("#{root}/")
puts Opencode::VERSION
'
push: push:
needs: verify
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 - 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,37 @@
# Changelog # Changelog
## 0.0.1.alpha9 - 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.
- Keep request instrumentation free of query credentials and private workspace
paths.
- Load non-empty todo events and written-file artifacts in standalone Ruby
clients.
- Bound reconnects after an interactive prompt loses its event stream, handle
DNS failures as transient stream errors, and map non-404 4xx responses to
`BadRequestError` consistently.
- Retry the Rails recovery recipe with its replacement session and keep
exception details out of user-facing message content.
### 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.alpha8 - 2026-07-20
### Yanked
- Published from the unrepaired alpha8 source by mistake and yanked the same
day. Use alpha9, which contains the intended alpha8 fixes and release gates.
## 0.0.1.alpha7 - 2026-07-18 ## 0.0.1.alpha7 - 2026-07-18
### Fixed ### Fixed
@@ -100,5 +132,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,8 +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, < 9.0)`*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.). - Runtime dependency: `marcel (~> 1.0)` for artifact MIME type detection.
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
@@ -213,10 +237,14 @@ 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 mistakenly published `0.0.1.alpha8` package contained unrepaired source and
`release.yml` workflow is registered as a trusted publisher with the `release` was yanked; use `0.0.1.alpha9` or later. The repository contains a tag-triggered
environment, pushing a `v*` tag builds, attests, and publishes the gem without `release.yml` workflow intended for RubyGems trusted publishing, but its
a long-lived RubyGems API key. trusted-publisher registration is not confirmed for alpha9. A `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

@@ -128,12 +128,13 @@ class GenerateAssistantReplyJob < ApplicationJob
end end
rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError
raise if attempted_recreate raise if attempted_recreate
message.conversation.recreate_opencode_session!(client) session_id = message.conversation.recreate_opencode_session!(client)
attempted_recreate = true attempted_recreate = true
retry retry
end end
rescue StandardError => e rescue StandardError => e
message&.update!(status: :errored, content: "An error occurred: #{e.message.truncate(200)}") Rails.logger.error(e.full_message)
message&.update!(status: :errored, content: "An error occurred. Please try again.")
end end
private private

View File

@@ -6,8 +6,10 @@
# non-Rails apps. # non-Rails apps.
require "active_support/core_ext/object/blank" # provides blank?, present?, presence require "active_support/core_ext/object/blank" # provides blank?, present?, presence
require "active_support/core_ext/object/duplicable" require "active_support/core_ext/object/duplicable"
require "active_support/core_ext/hash/keys" # provides Hash#deep_stringify_keys
require "active_support/core_ext/string/filters" # provides String#truncate require "active_support/core_ext/string/filters" # provides String#truncate
require "active_support/core_ext/numeric/bytes" # provides Integer#megabytes require "active_support/core_ext/numeric/bytes" # provides Integer#megabytes
require "marcel"
require_relative "opencode/version" require_relative "opencode/version"
require_relative "opencode/error" require_relative "opencode/error"

View File

@@ -230,18 +230,12 @@ module Opencode
request = Net::HTTP::Get.new(uri) request = Net::HTTP::Get.new(uri)
add_auth_header(request) add_auth_header(request)
response = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do response = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: uri.path) do
http_client.request(request) http_client.request(request)
end end
unless response.code.to_i.between?(200, 299) body = handle_response(response)
raise ServerError, "list_questions failed: HTTP #{response.code}#{response.body.to_s[0, 200]}" body.is_a?(Array) ? body : []
end
return [] if response.body.blank?
JSON.parse(response.body, symbolize_names: true)
rescue JSON::ParserError => e
raise ServerError, "list_questions returned invalid JSON: #{e.message}"
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}" raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
rescue Errno::ECONNREFUSED, SocketError => e rescue Errno::ECONNREFUSED, SocketError => e
@@ -255,6 +249,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,
@@ -263,7 +261,8 @@ module Opencode
Net::ReadTimeout, Net::ReadTimeout,
Errno::ECONNREFUSED, Errno::ECONNREFUSED,
Errno::ECONNRESET, Errno::ECONNRESET,
Errno::EPIPE Errno::EPIPE,
SocketError
].freeze ].freeze
# Opens SSE connection to GET /event, yields parsed events filtered by session_id. # Opens SSE connection to GET /event, yields parsed events filtered by session_id.
@@ -330,7 +329,7 @@ module Opencode
loop do loop do
now = Process.clock_gettime(Process::CLOCK_MONOTONIC) now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
deadline = check_deadline_or_suspend(now, deadline, timeout, reply) deadline = check_deadline(now, deadline, timeout)
# NOTE: first_event_deadline is *not* suspension-eligible. If the agent # NOTE: first_event_deadline is *not* suspension-eligible. If the agent
# never gets started we want to fail fast — a session that's blocked on # never gets started we want to fail fast — a session that's blocked on
@@ -357,9 +356,11 @@ module Opencode
http.read_timeout = 30 http.read_timeout = 30
subscription_callback_error = nil subscription_callback_error = nil
event_callback_error = nil
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 +387,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
@@ -438,14 +444,19 @@ module Opencode
# that's the whole point: a healthy long wait (user thinking # that's the whole point: a healthy long wait (user thinking
# for 30 minutes) keeps the container warm via heartbeats so # for 30 minutes) keeps the container warm via heartbeats so
# the reaper doesn't kill it mid-wait. # the reaper doesn't kill it mid-wait.
on_activity_tick&.call(event) begin
block.call(event) on_activity_tick&.call(event)
block.call(event)
rescue StandardError => error
event_callback_error = error
raise
end
return if terminal_session_event?(event) return if terminal_session_event?(event)
end end
end end
end end
rescue *TRANSIENT_SSE_ERRORS rescue *TRANSIENT_SSE_ERRORS
raise if subscription_callback_error raise if subscription_callback_error || event_callback_error
# Treat transport-level SSE disconnects like clean EOF: reconnect # Treat transport-level SSE disconnects like clean EOF: reconnect
# until an idle session event, the overall timeout, or first-event # until an idle session event, the overall timeout, or first-event
@@ -504,6 +515,11 @@ module Opencode
# the prompt resolves. Otherwise apply the normal deadline check. # the prompt resolves. Otherwise apply the normal deadline check.
def check_deadline_or_suspend(now, deadline, timeout, reply) def check_deadline_or_suspend(now, deadline, timeout, reply)
return now + timeout if reply&.prompt_blocked? return now + timeout if reply&.prompt_blocked?
check_deadline(now, deadline, timeout)
end
def check_deadline(now, deadline, timeout)
raise TimeoutError, "SSE stream timed out after #{timeout}s" if now > deadline raise TimeoutError, "SSE stream timed out after #{timeout}s" if now > deadline
deadline deadline
@@ -618,7 +634,7 @@ module Opencode
add_auth_header(request) add_auth_header(request)
response = nil response = nil
result = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do result = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.uri.path) do
response = http_client.request(request) response = http_client.request(request)
handle_response(response) handle_response(response)
end end
@@ -639,11 +655,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) ||
@@ -659,23 +686,30 @@ module Opencode
end end
def handle_response(response) def handle_response(response)
return {} if response.code.to_i == 204 status = response.code.to_i
return {} if status == 204
body = if response.body.present? body = parse_response_body(response, status)
JSON.parse(response.body, symbolize_names: true)
else
{}
end
case response.code.to_i case status
when 200..299 then body when 200..299 then body
when 400 then raise BadRequestError.new(error_message(body, "Bad request"), response: body)
when 404 then raise SessionNotFoundError.new(error_message(body, "Session not found"), response: body) when 404 then raise SessionNotFoundError.new(error_message(body, "Session not found"), response: body)
when 400..499 then raise BadRequestError.new(error_message(body, "Bad request"), response: body)
when 500..599 then raise ServerError.new(error_message(body, "Server error"), response: body) when 500..599 then raise ServerError.new(error_message(body, "Server error"), response: body)
else raise Error.new("Unexpected response: #{response.code}", response: body) else raise Error.new("Unexpected response: #{response.code}", response: body)
end end
end
def parse_response_body(response, status)
return {} if response.body.blank?
JSON.parse(response.body, symbolize_names: true)
rescue JSON::ParserError rescue JSON::ParserError
raise ServerError.new("Invalid JSON from OpenCode (HTTP #{response.code}): #{response.body&.truncate(200)}") if status.between?(200, 299)
raise ServerError.new("Invalid JSON from OpenCode (HTTP #{response.code}): #{response.body&.truncate(200)}")
end
{ message: response.body.to_s.truncate(200) }
end end
# OpenCode HTTP error bodies use a wrapped shape: { name:, data: { message:, kind?: } }. # OpenCode HTTP error bodies use a wrapped shape: { name:, data: { message:, kind?: } }.
@@ -683,6 +717,9 @@ module Opencode
# `body[:message]` is no longer populated for errors — only `body[:data][:message]`. # `body[:message]` is no longer populated for errors — only `body[:data][:message]`.
# We read both to keep older mock servers working in tests. # We read both to keep older mock servers working in tests.
def error_message(body, fallback) def error_message(body, fallback)
return body.truncate(200) if body.is_a?(String) && body.present?
return fallback unless body.is_a?(Hash)
body.dig(:data, :message) || body[:message] || fallback body.dig(:data, :message) || body[:message] || fallback
end end
end end

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.alpha9"
end end

View File

@@ -30,14 +30,10 @@ Gem::Specification.new do |spec|
%w[README.md LICENSE CHANGELOG.md opencode-ruby.gemspec] %w[README.md LICENSE CHANGELOG.md opencode-ruby.gemspec]
spec.require_paths = ["lib"] spec.require_paths = ["lib"]
# The only runtime dependency is ActiveSupport (NOT Rails). ActiveSupport # ActiveSupport supplies the small set of core extensions used by the
# is a standalone gem providing the `present?`/`blank?`/`presence`/ # client without pulling in Rails. Marcel identifies artifact content types.
# `truncate`/`duplicable?` helpers used in this gem's code. It does NOT
# pull in ActiveRecord, ActionView, ActionController, Turbo, or any other
# Rails-only piece. Most Ruby apps in the wild already have ActiveSupport
# transitively via another gem; in the rare case yours doesn't, ~250 LOC
# of core_ext is added when this gem installs.
spec.add_runtime_dependency "activesupport", ">= 6.1", "< 9.0" spec.add_runtime_dependency "activesupport", ">= 6.1", "< 9.0"
spec.add_runtime_dependency "marcel", "~> 1.0"
spec.add_development_dependency "minitest", "~> 5.20" spec.add_development_dependency "minitest", "~> 5.20"
spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rake", "~> 13.0"

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
require "test_helper"
class ConversationRecipeTest < Minitest::Test
RECIPE = File.read(File.expand_path("../examples/conversation_recipe.rb", __dir__))
def test_recovery_retries_with_the_recreated_session
assert_includes RECIPE,
"session_id = message.conversation.recreate_opencode_session!(client)"
end
def test_user_facing_error_does_not_include_exception_details
assert_includes RECIPE, 'content: "An error occurred. Please try again."'
refute_includes RECIPE, 'content: "An error occurred: #{e.message.truncate(200)}"'
end
def test_error_reporting_works_on_the_supported_rails_6_1_baseline
assert_includes RECIPE, "Rails.logger.error(e.full_message)"
refute_includes RECIPE, "Rails.error.report"
end
end

View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "test_helper" require "test_helper"
require "timeout"
# End-to-end smoke test of the gem's public surface. Validates that the # End-to-end smoke test of the gem's public surface. Validates that the
# headline `client.stream(...)` API + Reply::Result + error model + the # headline `client.stream(...)` API + Reply::Result + error model + the
@@ -126,6 +127,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 +387,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 = [
{ {
@@ -375,6 +500,70 @@ class SmokeTest < Minitest::Test
assert_equal [ true, false, true, false, false ], wait_states assert_equal [ true, false, true, false, false ], wait_states
end end
def test_prompt_wait_does_not_suspend_timeout_after_disconnect
question = {
type: "question.asked",
properties: { id: "que_1", sessionID: SESSION_ID, questions: [] }
}
stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(
status: 200,
body: "data: #{question.to_json}\n\n",
headers: { "Content-Type" => "text/event-stream" }
).then.to_raise(Errno::ECONNREFUSED)
reply = Opencode::Reply.new
assert_raises(Opencode::TimeoutError) do
Timeout.timeout(0.5) do
@client.stream_events(
session_id: SESSION_ID,
timeout: 0.01,
first_event_timeout: 1,
reply: reply
) { |event| reply.apply(event) }
end
end
end
def test_connected_prompt_wait_suspends_timeout_while_events_keep_arriving
question = {
type: "question.asked",
properties: { id: "que_1", sessionID: SESSION_ID, questions: [] }
}
replied = {
type: "question.replied",
properties: { requestID: "que_1", sessionID: SESSION_ID, answers: [ [ "yes" ] ] }
}
idle = {
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
}
response = Net::HTTPOK.new("1.1", "200", "OK")
response.define_singleton_method(:read_body) do |&callback|
callback.call("data: #{question.to_json}\n\n")
sleep 0.02
callback.call("data: #{replied.to_json}\n\n")
callback.call("data: #{idle.to_json}\n\n")
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) { |_request, &callback| callback.call(response) }
http.define_singleton_method(:started?) { false }
reply = Opencode::Reply.new
Net::HTTP.stub(:new, ->(_host, _port) { http }) do
@client.stream_events(
session_id: SESSION_ID,
timeout: 0.01,
first_event_timeout: 1,
reply: reply
) { |event| reply.apply(event) }
end
end
def test_stream_block_is_optional def test_stream_block_is_optional
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: "")
@@ -419,7 +608,13 @@ class SmokeTest < Minitest::Test
} }
sse = [ sse = [
CONNECTED_EVENT, CONNECTED_EVENT,
{ type: "todo.updated", properties: { sessionID: SESSION_ID, todos: [] } }, {
type: "todo.updated",
properties: {
sessionID: SESSION_ID,
todos: [ { content: "Plan", status: "in-progress", priority: "high" } ]
}
},
{ type: "message.part.updated", properties: { sessionID: SESSION_ID, part: skill_part } }, { type: "message.part.updated", properties: { sessionID: SESSION_ID, part: skill_part } },
{ type: "message.part.updated", properties: { sessionID: SESSION_ID, part: task_part } }, { type: "message.part.updated", properties: { sessionID: SESSION_ID, part: task_part } },
{ type: "message.part.delta", { type: "message.part.delta",
@@ -449,9 +644,33 @@ class SmokeTest < Minitest::Test
assert_equal "SUBAGENT_OK", reply.full_text assert_equal "SUBAGENT_OK", reply.full_text
assert_equal %w[todowrite skill task], reply.tool_parts.map { |part| part.fetch("tool") } assert_equal %w[todowrite skill task], reply.tool_parts.map { |part| part.fetch("tool") }
assert_equal(
[ { "content" => "Plan", "status" => "in_progress", "priority" => "high" } ],
reply.tool_parts.first.dig("input", "todos")
)
assert_equal "ses_child", reply.tool_parts.last.dig("metadata", "sessionId") assert_equal "ses_child", reply.tool_parts.last.dig("metadata", "sessionId")
end end
def test_write_artifacts_work_in_a_standalone_client
response = {
parts: [
{
type: "tool",
tool: "write",
state: {
status: "completed",
input: { filePath: "/tmp/report.md", content: "# Report" }
}
}
]
}
assert_equal(
[ { filename: "report.md", content: "# Report", content_type: "text/markdown" } ],
Opencode::ResponseParser.extract_artifact_files(response)
)
end
def test_connection_refused_raises_ConnectionError def test_connection_refused_raises_ConnectionError
stub_request(:get, "http://opencode.dead/global/health") stub_request(:get, "http://opencode.dead/global/health")
.to_raise(Errno::ECONNREFUSED) .to_raise(Errno::ECONNREFUSED)
@@ -470,6 +689,85 @@ class SmokeTest < Minitest::Test
end end
end end
def test_plain_text_404_preserves_SessionNotFoundError
stub_request(:get, "#{BASE}/session/missing/message")
.to_return(status: 404, body: "not found", headers: { "Content-Type" => "text/plain" })
assert_raises(Opencode::SessionNotFoundError) do
@client.get_messages("missing")
end
end
def test_non_404_client_errors_raise_BadRequestError
stub_request(:get, "#{BASE}/session")
.to_return(status: 401, body: { error: "unauthorized" }.to_json,
headers: { "Content-Type" => "application/json" })
assert_raises(Opencode::BadRequestError) { @client.list_sessions }
end
def test_plain_text_client_errors_raise_BadRequestError
stub_request(:get, "#{BASE}/session")
.to_return(status: 401, body: "unauthorized", headers: { "Content-Type" => "text/plain" })
assert_raises(Opencode::BadRequestError) { @client.list_sessions }
end
def test_non_object_json_client_errors_raise_BadRequestError
[ '"unauthorized"', "[]", "null" ].each do |body|
stub_request(:get, "#{BASE}/session")
.to_return(status: 401, body: body, headers: { "Content-Type" => "application/json" })
assert_raises(Opencode::BadRequestError) { @client.list_sessions }
end
end
def test_list_questions_uses_the_public_error_hierarchy
stub_request(:get, "#{BASE}/question")
.to_return(status: 401, body: { error: "unauthorized" }.to_json,
headers: { "Content-Type" => "application/json" })
assert_raises(Opencode::BadRequestError) { @client.list_questions }
end
def test_stream_retries_socket_errors_until_its_timeout
stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_raise(SocketError.new("name resolution failed"))
assert_raises(Opencode::StaleSessionError) do
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 0.01
) { }
end
end
def test_stream_does_not_swallow_socket_errors_from_the_caller_block
event = {
type: "message.part.updated",
properties: {
sessionID: SESSION_ID,
part: { id: "part_1", type: "text", text: "hello" }
}
}
stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(
status: 200,
body: "data: #{event.to_json}\n\n",
headers: { "Content-Type" => "text/event-stream" }
)
error = assert_raises(SocketError) do
Timeout.timeout(0.5) do
@client.stream_events(session_id: SESSION_ID, timeout: 1, first_event_timeout: 1) do
raise SocketError, "caller lookup failed"
end
end
end
assert_equal "caller lookup failed", error.message
end
def test_instrumentation_adapter_receives_request_events def test_instrumentation_adapter_receives_request_events
events = [] events = []
Opencode::Instrumentation.adapter = ->(name, payload, &block) { Opencode::Instrumentation.adapter = ->(name, payload, &block) {
@@ -486,6 +784,47 @@ class SmokeTest < Minitest::Test
"instrumentation adapter must receive opencode.request events" "instrumentation adapter must receive opencode.request events"
end end
def test_instrumentation_does_not_expose_scoped_query_values
events = []
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
events << [ name, payload ]
block.call
}
client = Opencode::Client.new(
base_url: "#{BASE}?token=base-secret",
directory: "/private/workspace",
workspace: "workspace-secret"
)
stub_request(:get, %r{#{Regexp.escape(BASE)}/session\?.*})
.to_return(status: 200, body: "[]",
headers: { "Content-Type" => "application/json" })
client.list_sessions
payload = events.find { |name, _| name == "opencode.request" }.last
assert_equal "/session", payload.fetch(:path)
end
def test_list_questions_instrumentation_does_not_expose_scoped_query_values
events = []
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
events << [ name, payload ]
block.call
}
client = Opencode::Client.new(
base_url: "#{BASE}?token=base-secret",
directory: "/private/workspace",
workspace: "workspace-secret"
)
stub_request(:get, %r{#{Regexp.escape(BASE)}/question\?.*})
.to_return(status: 200, body: "[]", headers: { "Content-Type" => "application/json" })
client.list_questions
payload = events.find { |name, _| name == "opencode.request" }.last
assert_equal "/question", payload.fetch(:path)
end
def test_Reply_distill_returns_typed_Result def test_Reply_distill_returns_typed_Result
parts = [ parts = [
{ "type" => "text", "content" => "hi" }, { "type" => "text", "content" => "hi" },

View File

@@ -0,0 +1,26 @@
# 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, "`0.0.1.alpha8` package contained unrepaired source"
assert_includes README, "was yanked"
assert_includes README, "registration is not confirmed for alpha9"
assert_includes README, "does not currently guarantee publication"
end
def test_compatibility_documents_every_runtime_dependency
assert_includes README, "`activesupport (>= 6.1, < 9.0)`"
assert_includes README, "`marcel (~> 1.0)`"
end
end

View File

@@ -0,0 +1,74 @@
# 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 verify_job
workflow.fetch("jobs").fetch("verify")
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 true, setup_ruby.dig("with", "bundler-cache")
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.key?("run") }
end
def test_release_tag_must_match_the_gem_version_before_publish
preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" }
refute_nil preflight
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
assert_includes preflight.fetch("run"), "unless Opencode::VERSION == expected"
end
def test_verification_job_has_read_only_credentials
assert_equal({ "contents" => "read" }, verify_job.fetch("permissions"))
checkout = verify_job.fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
assert_equal false, checkout.dig("with", "persist-credentials")
end
def test_release_verifies_the_supported_matrix_and_installed_gem_before_publish
assert_equal %w[3.2 3.3 3.4 4.0], verify_job.dig("strategy", "matrix", "ruby")
assert_equal "verify", push_job.fetch("needs")
commands = verify_job.fetch("steps").filter_map { |step| step["run"] }.join("\n")
assert_includes commands, "bundle exec rake test"
assert_includes commands, "gem build opencode-ruby.gemspec"
assert_includes commands, "bundle exec ruby -e"
assert_includes commands, 'GEM_HOME="${RUNNER_TEMP}/opencode-ruby-${{ matrix.ruby }}"'
assert_includes commands, 'gem_file="opencode-ruby-$(ruby -Ilib -ropencode/version'
assert_includes commands, 'gem install --local "$gem_file" --no-document'
assert_includes commands, "Gem.loaded_specs.fetch(\"opencode-ruby\").full_gem_path"
assert_includes commands, "ruby -ropencode-ruby"
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 7, 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