11 Commits

10 changed files with 340 additions and 26 deletions

View File

@@ -7,17 +7,18 @@ 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 - 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,17 @@
# Changelog # Changelog
## 0.0.1.alpha7 - 2026-07-18
### Fixed
- Add an at-most-once `on_subscribed` hook to the lower-level
`Opencode::Client#stream_events` API. Higher-level orchestrators can now
wait for `server.connected` before submitting `prompt_async` without
abandoning their own Reply observers, persistence, or recovery pipeline.
- Propagate subscription-hook failures directly and never invoke the hook
again on SSE reconnect. Ambiguous prompt transport failures therefore
cannot silently become duplicate turns.
## 0.0.1.alpha6 - 2026-07-18 ## 0.0.1.alpha6 - 2026-07-18
### Fixed ### Fixed
@@ -88,5 +100,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
@@ -119,6 +122,23 @@ client.stream_events(session_id: session_id) do |event|
end end
``` ```
Orchestrators that submit their own async prompt must do so through
`on_subscribed`; the callback runs only after the first `server.connected`
frame and at most once across automatic reconnects:
```ruby
client.stream_events(
session_id: session_id,
on_subscribed: -> { client.send_message_async(session_id, prompt) }
) do |event|
reply.apply(event)
end
```
If the callback raises, `stream_events` propagates that error and does not
retry it. This is intentional: a timed-out prompt response is ambiguous, so
reposting could duplicate the model turn and its cost.
### Interactive prompts ### Interactive prompts
When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via: When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via:
@@ -182,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
@@ -196,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

@@ -285,6 +285,13 @@ module Opencode
# without nuking real reasoning. Callers that know their agent is # without nuking real reasoning. Callers that know their agent is
# short-prompt + fast can pass a lower value. # short-prompt + fast can pass a lower value.
# #
# on_subscribed: optional callable invoked at most once, after the first
# `server.connected` frame proves the SSE response body is flowing. This
# is the safe place for higher-level orchestrators to submit prompt_async;
# reconnects wait for their own connected frame but never invoke it again.
# A raised callback error is propagated directly and is never treated as
# a reconnectable SSE transport failure.
#
# idle_stream_timeout: seconds to wait BETWEEN meaningful events once # idle_stream_timeout: seconds to wait BETWEEN meaningful events once
# the session has started producing them. Default nil = no check # the session has started producing them. Default nil = no check
# (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat # (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat
@@ -298,7 +305,7 @@ module Opencode
# translate into a user-visible error / retry affordance. # translate into a user-visible error / retry affordance.
def stream_events(session_id:, timeout: 600, first_event_timeout: 120, def stream_events(session_id:, timeout: 600, first_event_timeout: 120,
idle_stream_timeout: nil, idle_stream_timeout: nil,
reply: nil, on_activity_tick: nil, &block) reply: nil, on_activity_tick: nil, on_subscribed: nil, &block)
consume_event_stream( consume_event_stream(
session_id: session_id, session_id: session_id,
timeout: timeout, timeout: timeout,
@@ -306,6 +313,7 @@ module Opencode
idle_stream_timeout: idle_stream_timeout, idle_stream_timeout: idle_stream_timeout,
reply: reply, reply: reply,
on_activity_tick: on_activity_tick, on_activity_tick: on_activity_tick,
on_subscribed: on_subscribed,
&block &block
) )
end end
@@ -318,6 +326,7 @@ module Opencode
first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout
received_session_event = false received_session_event = false
last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
subscription_callback_attempted = on_subscribed.nil?
loop do loop do
now = Process.clock_gettime(Process::CLOCK_MONOTONIC) now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -390,6 +399,13 @@ module Opencode
# available readiness handshake before prompting. # available readiness handshake before prompting.
next unless event[:type] == "server.connected" next unless event[:type] == "server.connected"
turn_started = false
unless subscription_callback_attempted
# Mark the attempt before invoking the callback. A timeout
# can mean the prompt reached OpenCode even though its HTTP
# response did not reach us; retrying would duplicate the
# turn. The caller receives the original error instead.
subscription_callback_attempted = true
begin begin
turn_started = on_subscribed.call turn_started = on_subscribed.call
rescue StandardError => error rescue StandardError => error
@@ -399,6 +415,7 @@ module Opencode
subscription_callback_error = error subscription_callback_error = error
raise raise
end end
end
if turn_started if turn_started
# Before this fix stream_events began only after the prompt # Before this fix stream_events began only after the prompt
# POST returned. Preserve those timeout semantics: the turn # POST returned. Preserve those timeout semantics: the turn

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Opencode module Opencode
VERSION = "0.0.1.alpha6" VERSION = "0.0.1.alpha7"
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: "")
@@ -271,6 +322,70 @@ class SmokeTest < Minitest::Test
assert_requested prompt, times: 1 assert_requested prompt, times: 1
end end
def test_stream_events_invokes_on_subscribed_once_after_connected_across_reconnects
order = []
connections = [
[ CONNECTED_EVENT, { type: "server.heartbeat", properties: {} } ],
[
CONNECTED_EVENT,
{
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
}
]
]
event_stream = stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return do
events = connections.shift or raise "unexpected third SSE connection"
order << :sse_accepted
{
status: 200,
body: events.map { |event| "data: #{event.to_json}\n\n" }.join,
headers: { "Content-Type" => "text/event-stream" }
}
end
subscribed_calls = 0
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
subscribed_calls += 1
order << :prompt
true
}
) { |_event| }
assert_equal 1, subscribed_calls
assert_equal [ :sse_accepted, :prompt, :sse_accepted ], order
assert_requested event_stream, times: 2
end
def test_stream_events_surfaces_on_subscribed_timeout_without_reconnecting
event_stream = stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(status: 200, body: "data: #{CONNECTED_EVENT.to_json}\n\n",
headers: { "Content-Type" => "text/event-stream" })
calls = 0
error = assert_raises(Net::ReadTimeout) do
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
calls += 1
raise Net::ReadTimeout, "ambiguous prompt response"
}
) { |_event| }
end
assert_equal "Net::ReadTimeout with \"ambiguous prompt response\"", error.message
assert_equal 1, calls
assert_requested event_stream, times: 1
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,38 @@
# 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 }
refute steps.any? { |step| step.fetch("run", "").match?(/\bgem\s+push\b/) }
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