From fe953020d56a2708080b0948e2ffb4bd96936330 Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Sat, 18 Jul 2026 15:29:57 -0700 Subject: [PATCH] docs: make Rails quickstart match Turn API --- README.md | 57 ++++++++++++++++++++++++++++++++++++++------- test/readme_test.rb | 39 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 test/readme_test.rb diff --git a/README.md b/README.md index 76c7b4c..78b7acc 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,13 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay ```ruby # Gemfile -gem "opencode-ruby" # wire client + Reply state machine -gem "opencode-rails" # AR-coupled session/turn/artifact stack +gem "opencode-ruby", "= 0.0.1.alpha7" # wire client + Reply state machine + +# 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 @@ -33,9 +38,12 @@ 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. -Releases use RubyGems trusted publishing. Register `release.yml` as the gem's -trusted publisher (using the `release` environment); after that, a `v*` tag -publishes without a long-lived RubyGems API key. +`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 @@ -49,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 # app/jobs/generate_response_job.rb class GenerateResponseJob < ApplicationJob def perform(assistant_message) conversation = assistant_message.conversation 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( conversation, @@ -68,9 +92,15 @@ class GenerateResponseJob < ApplicationJob subject: conversation, query_text: user_message.content, 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) { - # result.status #=> :completed | :error | :cancelled + # result.status #=> :completed | :cancelled | :error | :failed # result.message #=> the AR row (reloaded) # result.duration_ms } @@ -87,7 +117,16 @@ class GenerateResponseJob < ApplicationJob 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 diff --git a/test/readme_test.rb b/test/readme_test.rb new file mode 100644 index 0000000..a039451 --- /dev/null +++ b/test/readme_test.rb @@ -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(?.*?)(?=^## )/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\((?.*?)^\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