Compare commits
19 Commits
v0.0.1.alp
...
a9add2a7c1
| Author | SHA1 | Date | |
|---|---|---|---|
| a9add2a7c1 | |||
| 61beb17f55 | |||
| 8b78a558e1 | |||
| 4d975d99b3 | |||
|
|
b27d39edf4 | ||
| 75e4435d9d | |||
| 6148fc67da | |||
|
|
deeccb119e | ||
| fe953020d5 | |||
|
|
2a391ccad1 | ||
| 7744fe211a | |||
|
|
17025f0ed9 | ||
| 451ef97b9f | |||
| b0e8cf8e20 | |||
| de51ff3a45 | |||
| ca08bb36e8 | |||
| a5bd656144 | |||
| ff6187118d | |||
| 3af78b9716 |
32
.github/workflows/release.yml
vendored
Normal file
32
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||||
|
with:
|
||||||
|
ruby-version: "4.0"
|
||||||
|
bundler-cache: true
|
||||||
|
- name: Verify tag matches gem version
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
run: >-
|
||||||
|
ruby -Ilib -ropencode/rails_version -e
|
||||||
|
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
|
||||||
|
abort "tag #{expected.inspect} does not match gem #{Opencode::RAILS_VERSION.inspect}"
|
||||||
|
unless Opencode::RAILS_VERSION == expected'
|
||||||
|
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
|
||||||
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -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@v4
|
- 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
|
||||||
@@ -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'
|
||||||
|
|||||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -1,5 +1,63 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.0.1.alpha8 - 2026-07-20
|
||||||
|
|
||||||
|
### Bumped
|
||||||
|
|
||||||
|
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha8`, carrying the
|
||||||
|
hardened SSE framing parser while retaining the alpha7 subscribe-before-
|
||||||
|
prompt and at-most-once reconnect contract.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Test the supported runtime surface on 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::RAILS_VERSION`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
4
Gemfile
4
Gemfile
@@ -1,3 +1,7 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "opencode-ruby",
|
||||||
|
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||||
|
ref: "9277646a4bb2cf25a8384ffc140b154f49ea5766"
|
||||||
|
|
||||||
gemspec
|
gemspec
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -17,10 +17,25 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
After both alpha8 gems are confirmed on RubyGems, pin the lockstep tuple:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
# Gemfile
|
# Gemfile
|
||||||
gem "opencode-ruby" # wire client + Reply state machine
|
gem "opencode-ruby", "= 0.0.1.alpha8" # wire client + Reply state machine
|
||||||
gem "opencode-rails" # AR-coupled session/turn/artifact stack
|
gem "opencode-rails", "= 0.0.1.alpha8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Until publication is verified, validate this candidate checkout against the
|
||||||
|
exact `opencode-ruby` source it was tested with:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Gemfile
|
||||||
|
gem "opencode-ruby",
|
||||||
|
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||||
|
ref: "9277646a4bb2cf25a8384ffc140b154f49ea5766"
|
||||||
|
|
||||||
|
gem "opencode-rails",
|
||||||
|
path: "../opencode-rails"
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -29,6 +44,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.alpha8
|
||||||
|
retains the subscribe-ready-before-prompt transport contract and reconnects an
|
||||||
|
accepted turn without posting its prompt again, while hardening SSE framing.
|
||||||
|
|
||||||
|
`opencode-rails` 0.0.1.alpha8 is a release candidate and is not yet confirmed
|
||||||
|
published on RubyGems. The `release.yml` workflow is prepared for RubyGems
|
||||||
|
trusted publishing, but the gem's trusted publisher must be registered and
|
||||||
|
verified for that workflow and its `release` environment. Until the registry
|
||||||
|
result is verified, pushing a `v*` tag does not guarantee publication. Trusted
|
||||||
|
publishing does not require a long-lived RubyGems API key.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
@@ -41,13 +67,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 +102,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 +127,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
|
||||||
|
|
||||||
|
|||||||
@@ -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.alpha8"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.alpha8"
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
53
test/readme_test.rb
Normal file
53
test/readme_test.rb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "ripper"
|
||||||
|
|
||||||
|
class ReadmeTest < Minitest::Test
|
||||||
|
README_PATH = File.expand_path("../README.md", __dir__)
|
||||||
|
GEMFILE_PATH = File.expand_path("../Gemfile", __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
|
||||||
|
|
||||||
|
def test_install_contract_tracks_candidate_versions_and_client_source
|
||||||
|
gemfile = File.read(GEMFILE_PATH)
|
||||||
|
client_ref = gemfile[/gem "opencode-ruby".*?ref:\s*"([0-9a-f]{40})"/m, 1]
|
||||||
|
|
||||||
|
refute_nil client_ref, "Gemfile must pin opencode-ruby to an exact commit"
|
||||||
|
assert_includes @readme, %(gem "opencode-ruby", "= #{Opencode::VERSION}")
|
||||||
|
assert_includes @readme, %(gem "opencode-rails", "= #{Opencode::RAILS_VERSION}")
|
||||||
|
assert_includes @readme, %(ref: "#{client_ref}")
|
||||||
|
assert_includes @readme,
|
||||||
|
"`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate"
|
||||||
|
assert_includes @readme, "pushing a `v*` tag does not guarantee publication"
|
||||||
|
end
|
||||||
|
end
|
||||||
53
test/release_workflow_test.rb
Normal file
53
test/release_workflow_test.rb
Normal 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::RAILS_VERSION == expected"
|
||||||
|
end
|
||||||
|
end
|
||||||
16
test/version_test.rb
Normal file
16
test/version_test.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Opencode::VersionTest < Minitest::Test
|
||||||
|
GEMSPEC_PATH = File.expand_path("../opencode-rails.gemspec", __dir__)
|
||||||
|
|
||||||
|
def test_alpha_versions_and_runtime_dependency_stay_in_lockstep
|
||||||
|
specification = Gem::Specification.load(GEMSPEC_PATH)
|
||||||
|
dependency = specification.runtime_dependencies.find { |item| item.name == "opencode-ruby" }
|
||||||
|
|
||||||
|
refute_nil dependency
|
||||||
|
assert_equal Opencode::RAILS_VERSION, Opencode::VERSION
|
||||||
|
assert_equal "= #{Opencode::RAILS_VERSION}", dependency.requirement.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
67
test/workflow_contract_test.rb
Normal file
67
test/workflow_contract_test.rb
Normal 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
|
||||||
Reference in New Issue
Block a user