15 Commits

Author SHA1 Message Date
ajay@krishnan.ca
b27d39edf4 Merge pull request 'Run trusted gem releases only on GitHub' (#1) from fix/github-only-release-runner-guard-20260719 into main 2026-07-19 12:37:48 -07:00
75e4435d9d Test GitHub-only gem release boundary 2026-07-19 06:50:51 -07:00
6148fc67da Run gem releases only on GitHub 2026-07-19 04:30:50 -07:00
Ajay Krishnan
deeccb119e Merge pull request #3 from ajaynomics/docs/readme-executable-quickstart
docs: make alpha7 Rails quickstart executable
2026-07-18 15:31:57 -07:00
fe953020d5 docs: make Rails quickstart match Turn API 2026-07-18 15:29:57 -07:00
Ajay Krishnan
2a391ccad1 Merge pull request #2 from ajaynomics/fix/turn-subscribe-before-prompt-alpha7
Submit Rails turns after SSE readiness
2026-07-18 14:39:25 -07:00
7744fe211a Submit Rails turns after SSE readiness 2026-07-18 14:37:24 -07:00
Ajay Krishnan
17025f0ed9 Merge pull request #1 from ajaynomics/release/alpha6
Release opencode-rails 0.0.1.alpha6
2026-07-18 13:43:15 -07:00
451ef97b9f Build packaged dependency from its source root 2026-07-18 13:41:14 -07:00
b0e8cf8e20 Make loading provenance assertion path-specific 2026-07-18 13:40:03 -07:00
de51ff3a45 Make lockstep dependency CI-resolvable 2026-07-18 13:38:22 -07:00
ca08bb36e8 Add trusted gem publishing 2026-07-18 13:32:36 -07:00
a5bd656144 Release opencode-rails alpha6 2026-07-18 13:31:13 -07:00
ff6187118d Release opencode-rails alpha5
Some checks failed
Test / test (3.2) (push) Failing after 10s
Test / test (3.3) (push) Failing after 11s
Test / test (3.4) (push) Failing after 11s
2026-07-15 20:20:17 -07:00
3af78b9716 Release opencode-rails alpha4
Some checks failed
Test / test (3.2) (push) Failing after 1s
Test / test (3.3) (push) Failing after 1s
Test / test (3.4) (push) Failing after 1s
2026-07-12 01:55:56 -07:00
12 changed files with 389 additions and 34 deletions

24
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Push gem
on:
push:
tags:
- "v*"
jobs:
push:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
environment: release
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: ruby
bundler-cache: true
- uses: rubygems/release-gem@v1

View File

@@ -14,7 +14,7 @@ jobs:
matrix: matrix:
ruby: ["3.2", "3.3", "3.4"] ruby: ["3.2", "3.3", "3.4"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Ruby ${{ matrix.ruby }} - name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
@@ -29,11 +29,9 @@ jobs:
run: gem build opencode-rails.gemspec run: gem build opencode-rails.gemspec
- name: Verify gem loads after install - name: Verify gem loads after install
# opencode-rails depends on opencode-ruby; until both gems
# are on rubygems.org, the install step here will only resolve
# if opencode-ruby has been pre-installed or is reachable.
# When the gems do publish, the runtime_dependency on
# opencode-ruby will Just Work via rubygems.
run: | run: |
gem install --local opencode-rails-*.gem --conservative client_dir="$(bundle show opencode-ruby)"
(cd "$client_dir" && gem build opencode-ruby.gemspec)
gem install "$client_dir"/opencode-ruby-*.gem --no-document
gem install opencode-rails-*.gem --no-document
ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION' ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION'

View File

@@ -1,5 +1,47 @@
# Changelog # Changelog
## 0.0.1.alpha7 - 2026-07-18
### Fixed
- Make `Opencode::Turn` submit `prompt_async` through the transport's
at-most-once `on_subscribed` callback, after `server.connected` proves the
SSE listener is ready. Reconnects reopen only SSE and never replay the user
prompt.
- Fail the turn directly when subscription setup or the prompt POST fails
before a turn is confirmed started. The recovery path no longer risks
finalizing stale exchange text after a pre-turn failure.
- Add a gem-level behavioral regression for the cross-gem ordering contract,
including reconnect and ambiguous prompt timeout cases.
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha7`.
## 0.0.1.alpha6 - 2026-07-18
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha6`. This exposed
subscribe-before-prompt through `Client#stream`, but the lower-level
`Opencode::Turn` path still sent before `stream_events`; that orchestration
gap is fixed in alpha7.
## 0.0.1.alpha5 - 2026-07-15
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha5`, exposing
native parent-linked and configured session creation to Rails hosts.
## 0.0.1.alpha4 - 2026-07-12
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha4`, adding
current `session.status` idle handling and correct multi-assistant tool-loop
finalization for Rails turns.
## 0.0.1.alpha3 - 2026-07-10 ## 0.0.1.alpha3 - 2026-07-10
### Bumped ### Bumped

View File

@@ -1,3 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "78b6f9c9e9c7d58b699af1c3c17764acd33de798"
gemspec gemspec

View File

@@ -19,8 +19,13 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
```ruby ```ruby
# Gemfile # Gemfile
gem "opencode-ruby" # wire client + Reply state machine gem "opencode-ruby", "= 0.0.1.alpha7" # wire client + Reply state machine
gem "opencode-rails" # AR-coupled session/turn/artifact stack
# v0.0.1.alpha7 is a GitHub source release, not a RubyGems release. Pin the
# peeled release commit so a moved tag cannot change the code Bundler installs.
gem "opencode-rails",
git: "https://github.com/ajaynomics/opencode-rails.git",
ref: "2a391ccad1d098e4bb51eb19b6ca52adaa79e5cb"
``` ```
```bash ```bash
@@ -29,6 +34,17 @@ bundle install
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives. Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha7
uses a subscribe-ready-before-prompt transport contract and reconnects an
accepted turn without posting its prompt again.
`opencode-rails` 0.0.1.alpha7 is available as a GitHub source release but has
not been published to RubyGems. The `release.yml` workflow is prepared for
RubyGems trusted publishing, but the gem's trusted publisher still has to be
registered for that workflow and its `release` environment. Until then, pushing
a `v*` tag does not make the gem installable from RubyGems; use the exact Git
commit above. Trusted publishing does not require a long-lived RubyGems API key.
## Quickstart ## Quickstart
```ruby ```ruby
@@ -41,13 +57,29 @@ Opencode::ErrorReporter.adapter = ->(error, **opts) {
} }
``` ```
```ruby
# app/services/noop_reply_observer.rb
#
# Turn requires an observer factory even when the app does not need live
# partial rendering. For a streaming UI, replace this with an observer that
# persists/broadcasts selected ReplyObserver callbacks (and throttle writes).
class NoopReplyObserver
include Opencode::ReplyObserver
def watch(reply)
reply.add_observer(self)
self
end
end
```
```ruby ```ruby
# app/jobs/generate_response_job.rb # app/jobs/generate_response_job.rb
class GenerateResponseJob < ApplicationJob class GenerateResponseJob < ApplicationJob
def perform(assistant_message) def perform(assistant_message)
conversation = assistant_message.conversation conversation = assistant_message.conversation
user_message = conversation.messages.where(role: :user).last user_message = conversation.messages.where(role: :user).last
client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"]) client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
session = Opencode::Session.new( session = Opencode::Session.new(
conversation, conversation,
@@ -60,9 +92,15 @@ class GenerateResponseJob < ApplicationJob
subject: conversation, subject: conversation,
query_text: user_message.content, query_text: user_message.content,
client: client, client: client,
session: session, session_for: session,
observer_factory: ->(_message) { NoopReplyObserver.new },
system_context: ->(record) { "You are assisting with #{record.title}." },
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
tracer: ->(name, **payload) {
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
},
on_turn_finished: ->(result) { on_turn_finished: ->(result) {
# result.status #=> :completed | :error | :cancelled # result.status #=> :completed | :cancelled | :error | :failed
# result.message #=> the AR row (reloaded) # result.message #=> the AR row (reloaded)
# result.duration_ms # result.duration_ms
} }
@@ -79,7 +117,16 @@ class GenerateResponseJob < ApplicationJob
end end
``` ```
The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`. The host record (here `conversation`) must respond to `#title`,
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
`#error!(content)`. A non-no-op observer may impose additional record methods
for its own live snapshots.
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
constructor is intentionally explicit and may change before 1.0, so keep this
wiring in one host service/job and keep the gem source pinned exactly.
## What you get ## What you get

View File

@@ -11,5 +11,5 @@
# We can't reuse the same constant from a second gem, so we use a # We can't reuse the same constant from a second gem, so we use a
# distinct, non-namespaced constant. # distinct, non-namespaced constant.
module Opencode module Opencode
RAILS_VERSION = "0.0.1.alpha3" RAILS_VERSION = "0.0.1.alpha7"
end end

View File

@@ -213,12 +213,6 @@ module Opencode
emit_session_created_if_new emit_session_created_if_new
validate_session!(session_id) validate_session!(session_id)
@client.send_message_async(
session_id, @query_text,
agent: @agent_name.call(@subject),
system: @system_context.call(@subject)
)
stream_result = stream_response(session_id) stream_result = stream_response(session_id)
exchange = fetch_current_exchange(session_id) exchange = fetch_current_exchange(session_id)
stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange) stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange)
@@ -260,6 +254,22 @@ module Opencode
last_activity_touch_at = stream_started_at last_activity_touch_at = stream_started_at
first_token_at = nil first_token_at = nil
event_count = 0 event_count = 0
prompt_attempted = false
prompt_succeeded = false
on_subscribed = lambda do
# stream_events guarantees at-most-once invocation, but keep this
# guard here as a second line of defense because an ambiguous prompt
# response must never become a duplicate model turn.
next false if prompt_attempted
prompt_attempted = true
@client.send_message_async(
session_id, @query_text,
agent: @agent_name.call(@subject),
system: @system_context.call(@subject)
)
prompt_succeeded = true
end
begin begin
release_active_record_connections release_active_record_connections
@@ -293,7 +303,8 @@ module Opencode
@client.stream_events( @client.stream_events(
session_id: session_id, session_id: session_id,
reply: reply, reply: reply,
on_activity_tick: activity_tick on_activity_tick: activity_tick,
on_subscribed: on_subscribed
) do |event| ) do |event|
event_count += 1 event_count += 1
reply.apply(event) reply.apply(event)
@@ -308,6 +319,13 @@ module Opencode
rescue Opencode::SessionNotFoundError rescue Opencode::SessionNotFoundError
raise raise
rescue StandardError => e rescue StandardError => e
# Subscription rejection or prompt transport failure happened before
# a turn was confirmed started. Recovering from the pre-turn exchange
# could finalize stale text, and retrying an ambiguous POST could
# duplicate spend, so surface the original failure to the outer error
# path without reconnect/recovery.
raise unless prompt_succeeded
Opencode::ErrorReporter.report(e, handled: true, severity: :warning, Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
context: { feature: @error_feature, error_class: e.class.name }) context: { feature: @error_feature, error_class: e.class.name })
emit("stream.interrupted", emit("stream.interrupted",

View File

@@ -37,7 +37,7 @@ Gem::Specification.new do |spec|
# this gem builds on. During alpha both gems evolve in lockstep — we pin # this gem builds on. During alpha both gems evolve in lockstep — we pin
# exactly (= not ~>) so that consumers always pick the version this gem # exactly (= not ~>) so that consumers always pick the version this gem
# was tested against. # was tested against.
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha3" spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha7"
# Rails sub-libraries used at runtime. Depending on these individually # Rails sub-libraries used at runtime. Depending on these individually
# (instead of the `rails` umbrella) avoids forcing host apps to load # (instead of the `rails` umbrella) avoids forcing host apps to load

View File

@@ -36,23 +36,25 @@ class Opencode::LoadingTest < Minitest::Test
end end
end end
# We check via path match on both directory ("/opencode-rails/") and # Match the gem's own lib path, not merely any parent directory. GitHub's
# installed-gem name ("/opencode-rails-VERSION/") so the assertion is # checkout layout nests Bundler under /opencode-rails/opencode-rails, so a
# robust to either a sibling-repo dev setup or a bundle-resolved gem # broad directory-name assertion falsely classifies a bundled
# install. # opencode-ruby source path as belonging to this gem.
GEM_PATH_PATTERN = ->(name) { %r{/#{Regexp.escape(name)}[-/]} } GEM_SOURCE_PATTERN = lambda do |name, file|
%r{/#{Regexp.escape(name)}(?:-[^/]+)?/lib/opencode/#{Regexp.escape(file)}\.rb\z}
end
def test_session_constant_points_at_this_gem def test_session_constant_points_at_this_gem
location = Opencode::Session.instance_method(:initialize).source_location.first location = Opencode::Session.instance_method(:initialize).source_location.first
assert_match GEM_PATH_PATTERN.call("opencode-rails"), location, assert_match GEM_SOURCE_PATTERN.call("opencode-rails", "session"), location,
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}" "Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
end end
def test_client_constant_points_at_opencode_ruby def test_client_constant_points_at_opencode_ruby
location = Opencode::Client.instance_method(:initialize).source_location.first location = Opencode::Client.instance_method(:initialize).source_location.first
assert_match GEM_PATH_PATTERN.call("opencode-ruby"), location, assert_match GEM_SOURCE_PATTERN.call("opencode-ruby", "client"), location,
"Expected Opencode::Client to come from opencode-ruby, got: #{location}" "Expected Opencode::Client to come from opencode-ruby, got: #{location}"
refute_match GEM_PATH_PATTERN.call("opencode-rails"), location, refute_match GEM_SOURCE_PATTERN.call("opencode-rails", "client"), location,
"Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)" "Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)"
end end

View File

@@ -3,11 +3,107 @@
require "test_helper" require "test_helper"
# Contract smoke for Opencode::Turn (the orchestrator) and its inner # Contract smoke for Opencode::Turn (the orchestrator) and its inner
# Result value object. Behavioral coverage (the full send -> stream -> # Result value object. Most ActiveRecord behavior lives in host applications,
# recover -> finalize loop) lives in the host application — Turn needs # but the subscribe-before-prompt ordering is a cross-gem transport contract
# an Opencode::Client, an AR Message, a subject record, etc., which are # and belongs here so a host cannot silently bypass opencode-ruby's guarantee.
# all integration-level concerns.
class Opencode::TurnTest < Minitest::Test class Opencode::TurnTest < Minitest::Test
SESSION_ID = "ses_turn_test"
class FakeMessage
attr_reader :id, :finalized, :error_content
attr_accessor :cost, :input_tokens, :output_tokens, :tool_calls_json
def initialize
@id = 12
end
def reload = self
def cancelled? = false
def finalize!(**attrs)
@finalized = attrs
@cost = attrs[:cost]
@input_tokens = attrs[:input_tokens]
@output_tokens = attrs[:output_tokens]
@tool_calls_json = attrs[:tool_calls_json]
true
end
def error!(content)
@error_content = content
end
end
FakeSubject = Struct.new(:id, :opencode_session_id, keyword_init: true)
class FakeSession
def ensure!(_client) = SESSION_ID
def just_created? = false
end
class FakeObserver
def watch(_reply); end
end
class OrderedClient
attr_reader :order, :prompt_count, :message_reads
def initialize(prompt_error: nil)
@order = []
@prompt_count = 0
@message_reads = 0
@prompt_error = prompt_error
end
def get_messages(_session_id)
@message_reads += 1
@order << (@prompt_count.zero? ? :messages_before : :messages_after)
return [] if @prompt_count.zero?
[
{ info: { role: "user" }, parts: [ { type: "text", text: "ping" } ] },
{
info: {
role: "assistant", finish: "stop",
time: { created: 1, completed: 2 },
cost: 0.01,
tokens: { input: 2, output: 1 }
},
parts: [ { type: "text", text: "pong" } ]
}
]
end
def send_message_async(session_id, text, agent:, system:)
@prompt_count += 1
@order << :prompt
raise @prompt_error if @prompt_error
raise "wrong prompt" unless session_id == SESSION_ID && text == "ping"
raise "wrong routing" unless agent == "test-agent" && system == "test-system"
{}
end
def stream_events(session_id:, reply:, on_activity_tick:, on_subscribed:)
raise "wrong session" unless session_id == SESSION_ID
raise "missing reply" unless reply.is_a?(Opencode::Reply)
raise "missing activity callback" unless on_activity_tick.respond_to?(:call)
@order << :sse_ready
on_subscribed.call
@order << :sse_reconnected
on_subscribed.call
yield(
type: "message.part.delta",
properties: { sessionID: SESSION_ID, partID: "p1", field: "text", delta: "pong" }
)
yield(
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
)
end
end
REQUIRED_INIT_KEYS = %i[ REQUIRED_INIT_KEYS = %i[
message subject query_text client session_for observer_factory message subject query_text client session_for observer_factory
system_context agent_name tracer system_context agent_name tracer
@@ -59,4 +155,56 @@ class Opencode::TurnTest < Minitest::Test
assert_equal 100, result.input_tokens assert_equal 100, result.input_tokens
assert_equal 50, result.output_tokens assert_equal 50, result.output_tokens
end end
def test_turn_subscribes_before_prompt_and_never_reprompts_on_reconnect
client = OrderedClient.new
message = FakeMessage.new
results = []
build_turn(client:, message:, results:).call
assert_equal 1, client.prompt_count
assert_equal(
[ :messages_before, :sse_ready, :prompt, :sse_reconnected, :messages_after ],
client.order
)
assert_equal "pong", message.finalized.fetch(:content)
assert_nil message.error_content
assert results.last.completed?
end
def test_turn_does_not_recover_or_retry_an_ambiguous_prompt_failure
client = OrderedClient.new(prompt_error: Net::ReadTimeout.new("prompt timed out"))
message = FakeMessage.new
results = []
build_turn(client:, message:, results:).call
assert_equal 1, client.prompt_count
assert_equal 1, client.message_reads
assert_nil message.finalized
assert_equal Opencode::Turn::ERROR_FALLBACK_CONTENT, message.error_content
assert results.last.failed?
assert_instance_of Net::ReadTimeout, results.last.error
end
private
def build_turn(client:, message:, results:)
Opencode::Turn.new(
message: message,
subject: FakeSubject.new(id: 34, opencode_session_id: SESSION_ID),
query_text: "ping",
client: client,
session_for: FakeSession.new,
observer_factory: ->(_message) { FakeObserver.new },
system_context: ->(_subject) { "test-system" },
agent_name: ->(_subject) { "test-agent" },
tracer: ->(_name, **_payload) {},
on_turn_finished: ->(result) { results << result },
empty_stream_retry_delay: 0,
final_exchange_timeout: 0,
final_exchange_retry_delay: 0
)
end
end end

39
test/readme_test.rb Normal file
View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
require "test_helper"
require "ripper"
class ReadmeTest < Minitest::Test
README_PATH = File.expand_path("../README.md", __dir__)
def setup
@readme = File.read(README_PATH)
@quickstart = @readme[/^## Quickstart\n(?<body>.*?)(?=^## )/m, :body]
refute_nil @quickstart, "README must retain a Quickstart section"
end
def test_quickstart_turn_call_documents_every_required_keyword
turn_call = @quickstart[/Opencode::Turn\.new\((?<args>.*?)^\s*\)\.call/m, :args]
refute_nil turn_call, "Quickstart must contain an Opencode::Turn.new(...).call example"
documented = turn_call.scan(/^\s*([a-z_]+):/).flatten.map(&:to_sym)
parameters = Opencode::Turn.instance_method(:initialize).parameters
required = parameters.filter_map { |kind, name| name if kind == :keyreq }
accepted = parameters.filter_map { |kind, name| name if %i[keyreq key].include?(kind) }
assert_empty required - documented,
"Quickstart is missing required Turn keywords: #{(required - documented).join(", ")}"
assert_empty documented - accepted,
"Quickstart uses unsupported Turn keywords: #{(documented - accepted).join(", ")}"
refute_includes documented, :session
end
def test_readme_ruby_fences_parse
ruby_fences = @readme.scan(/```ruby\n(.*?)```/m).flatten
refute_empty ruby_fences
ruby_fences.each_with_index do |source, index|
assert Ripper.sexp(source), "README Ruby fence #{index + 1} has invalid syntax"
end
end
end

View File

@@ -0,0 +1,33 @@
# 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")
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")
assert_equal 1, steps.count { |step| step["uses"] == "rubygems/release-gem@v1" }
refute steps.any? { |step| step.fetch("run", "").match?(/\bgem\s+push\b/) }
end
end