1 Commits

Author SHA1 Message Date
9b0c4cd3cd Initial public release v0.0.1.alpha2
Some checks failed
Test / test (3.2) (push) Failing after 9m43s
Test / test (3.3) (push) Failing after 10m0s
Test / test (3.4) (push) Failing after 10m0s
opencode-rails — production-grade Rails integration for OpenCode.

Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle
(idempotent ensure!/recreate!/abort! with row-level locks), a Turn
orchestrator driving the Reply state machine and recovering from
session-not-found, an artifact pipeline backed by ActiveStorage,
sandbox seeding, and tool-display value objects for Turbo Stream
broadcasts. Drop into any Rails 7.1+ app that wants production-grade
OpenCode streaming without rolling boilerplate.

What this version ships:
  - Opencode::Session (AR-coupled lifecycle, row-level locks)
  - Opencode::Turn (Reply state machine, session-not-found recovery)
  - Opencode::Exchange (one turn = one request/response unit)
  - Opencode::Impostor (deterministic mock for tests)
  - Opencode::Sandbox / SandboxFile (per-session FS scratch space)
  - Opencode::Transform (host-rendered artifact pipeline)
  - Opencode::Artifact / MessageArtifacts (ActiveStorage-backed)
  - Opencode::UploadedFilesPrompt (system-prompt builder)
  - Opencode::ToolDisplay (Turbo Stream value objects)
  - Opencode::ErrorReporter (pluggable adapter — Honeybadger/Sentry/etc.)
  - examples/rails_integration.rb — canonical wiring blueprint

53 smoke tests. CI on Ruby 3.2/3.3/3.4.

Ruby >= 3.2. Runtime deps: opencode-ruby = 0.0.1.alpha2,
activerecord/activestorage/activesupport >= 7.1, < 9.0.

See CHANGELOG.md for the alpha1 -> alpha2 delta.
2026-05-25 06:49:09 -07:00
31 changed files with 880 additions and 75 deletions

39
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4"]
steps:
- uses: actions/checkout@v4
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run tests
run: bundle exec rake test
- name: Build gem
run: gem build opencode-rails.gemspec
- 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: |
gem install --local opencode-rails-*.gem --conservative
ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION'

View File

@@ -1,8 +1,24 @@
# Changelog
## 0.0.1.alpha2 — 2026-05-20
### Changed
- `Opencode::Exchange` now emits `opencode.apply_patch.artifacts_dropped`
via the new `Opencode::Instrumentation.notify` fire-and-forget API
(introduced in opencode-ruby v0.0.1.alpha2) instead of
`.instrument(name, payload) { }` with an empty block. Cleaner read
at the call site; identical semantics on the wire (same event name,
same payload).
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha2` (was
`= 0.0.1.alpha1`). Versions stay in lockstep during alpha.
## 0.0.1.alpha1 — 2026-05-20
Initial public alpha. Extracted from a production multi-product Rails app (`ajent-rails`) where these objects shipped under `lib/opencode/rails/` before being carved out into a standalone gem.
Initial public alpha. Extracted from a production Rails app where these objects shipped as in-tree library code before being carved out into a standalone gem.
**Includes:**
@@ -29,6 +45,6 @@ Initial public alpha. Extracted from a production multi-product Rails app (`ajen
**Known limitations (alpha):**
- Apply-patch tool's post-write file content is not extracted (wire-format limitation in OpenCode v1.15+); affected files surface via the `opencode.apply_patch.artifacts_dropped` instrumentation event. Future work: optional sandbox-read fallback path.
- Smoke tests only inside the gem (14 tests). Behavioral coverage lives in the host app that originally produced this code (`ajent-rails`'s `test/lib/opencode/rails/`). Standalone gem-side test suite using Combustion is open work.
- Smoke tests only inside the gem. Behavioral coverage currently lives in the host app that produced this code. A standalone gem-side test suite using Combustion is open work.
- No generator (`rails g opencode:install`) yet.
- No Rails Engine integration — `require "opencode-rails"` is sufficient.

73
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,73 @@
# Contributing to opencode-rails
## Running the test suite
```bash
bundle install
bundle exec rake test
```
The smoke tests live in `test/opencode/`. They prove that:
- Every gem-provided constant resolves
- The opencode-ruby umbrella loads transitively
- Source locations point at the right gem
- The version constant is not under an `Opencode::Rails` module
(that would shadow `::Rails` in host apps; see comment in
`lib/opencode/rails_version.rb`)
- Public API contracts on the AR-coupled classes hold (Session, Turn,
MessageArtifacts) — verified via `Method#parameters`, not behavior
- Value objects (Artifact, SandboxFile, Transform, Impostor) round-trip
through their public interfaces
Behavioral tests for AR + ActiveStorage paths live in the host app
that produced this code. Same pattern as opencode-ruby — gem-side
smokes prove load correctness; host-side tests prove integration
correctness.
## Working on opencode-rails together with opencode-ruby
opencode-rails depends on opencode-ruby. During development of either
gem you frequently need changes in opencode-ruby to be picked up by
opencode-rails without going through a release cycle.
**Use Bundler's `local` config — not Gemfile conditionals.** Bundler
behavior must never depend on filesystem state inside the Gemfile.
```bash
# Once per dev machine. Replace the path with wherever you have
# opencode-ruby checked out.
bundle config local.opencode-ruby /path/to/opencode-ruby
# Then bundle install/update against the local copy:
bundle install
```
To switch back to the released version:
```bash
bundle config --delete local.opencode-ruby
bundle install
```
See [Bundler's documentation on local git overrides](https://bundler.io/v2.5/git.html#local).
## Releasing
This gem is in alpha. Versions ship as `0.0.x.alphaN` until the public
API stabilizes.
Coordinated releases with opencode-ruby:
1. In opencode-ruby: bump `Opencode::VERSION`, tag, push.
2. In opencode-rails: bump `Opencode::RAILS_VERSION`, update the
`add_runtime_dependency "opencode-ruby", "= X.Y.Z"` line in the
gemspec to match the new opencode-ruby version (alpha discipline:
pin exactly, not pessimistically). Tag, push.
3. In any consumer app: bump both `tag:` lines (or version pins) in
the Gemfile to the new versions; `bundle update opencode-ruby
opencode-rails`.
## Reporting issues
File at <https://github.com/ajaynomics/opencode-rails/issues>.

View File

@@ -1,11 +1,3 @@
source "https://rubygems.org"
# opencode-ruby is the underlying wire client. During alpha both gems
# evolve in lockstep; the gemspec pins a release version, but for local
# dev we pull from the sibling working copy so changes in opencode-ruby
# are picked up without a release cycle.
if File.exist?(File.expand_path("../opencode-ruby", __dir__))
gem "opencode-ruby", path: File.expand_path("../opencode-ruby", __dir__)
end
gemspec

View File

@@ -1,6 +1,6 @@
# opencode-rails
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://gitea.krishnan.ca/ajaynomics/opencode-ruby).
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://github.com/ajaynomics/opencode-ruby).
> **Alpha software.** API will change before 1.0. Pin to a specific version.

View File

@@ -0,0 +1,215 @@
# frozen_string_literal: true
# examples/rails_integration.rb
#
# Production-shaped integration of opencode-rails into a Rails app.
# This file is NOT loaded by the gem at runtime — it's a reference
# blueprint. Drop the patterns into your app and adapt to your
# domain (Conversation/Message/User naming, ActiveStorage attachments,
# Turbo broadcasts).
#
# The pattern is extracted from a production Rails app where it ships
# multiple OpenCode-backed conversational products. It works for any
# host that has:
#
# - A "conversation" AR row that owns an `opencode_session_id:string`
# column and a has_many :messages association.
# - A "message" AR row with `role:string, status:string,
# parts_json:jsonb, content:text` (plus whatever fields your domain needs).
# - SolidQueue (or Sidekiq, GoodJob) for the background job.
# - Turbo for live streaming UX (optional but assumed).
#
# What each section demonstrates:
#
# 1. Initializer: route Instrumentation + ErrorReporter adapters
# to ActiveSupport::Notifications and Rails.error.report.
# 2. Job: orchestrate one turn with Opencode::Session + Opencode::Turn.
# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts.
# 4. Permissions builder: per-product session permission rules.
#
# Tested patterns. Every line below has been exercised in production.
# Copy what you need; adapt to your domain.
# ----------------------------------------------------------------------
# 1. config/initializers/opencode.rb
# ----------------------------------------------------------------------
#
# Wire the two adapters opencode-ruby + opencode-rails ship with. The
# gems are silent by default; the host explicitly opts into routing
# events to its observability stack.
Rails.application.config.to_prepare do
# opencode-ruby: every HTTP request, SSE event lifecycle, recovery
# path flows through this adapter as an ActiveSupport::Notifications
# event. Subscribers in this app pick it up via
# `ActiveSupport::Notifications.subscribe("opencode.*", ...)`.
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
ActiveSupport::Notifications.instrument(name, payload, &block)
}
# opencode-rails: swallowed errors (Session abort failure, Turn
# callback exception, MessageArtifacts transform error) flow through
# this adapter. Wire your Honeybadger / Sentry / Rails.error reporter
# here.
Opencode::ErrorReporter.adapter = ->(error, **opts) {
Rails.error.report(error, **opts)
}
end
# ----------------------------------------------------------------------
# 2. app/jobs/generate_response_job.rb
# ----------------------------------------------------------------------
#
# One job per assistant message. Idempotent on the message row: if
# the message is already :completed or :error, the job is a no-op.
# The Turn class handles all the orchestration; this job is mostly
# wiring + error fallback.
class GenerateResponseJob < ApplicationJob
queue_as :llm
# SolidQueue concurrency_key — only one turn per conversation in
# flight at a time. Without this, a user sending two messages back-
# to-back can race two turns through the same OpenCode session.
limits_concurrency to: 1, key: ->(message) { "GenerateResponseJob/#{message.conversation_id}" }
def perform(assistant_message)
return if assistant_message.terminal? # idempotent
conversation = assistant_message.conversation
user_message = conversation.messages.where(role: :user).order(:created_at).last
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
# OpenCode session if conversation.opencode_session_id is blank;
# returns the existing id otherwise.
session = Opencode::Session.new(
conversation,
permissions_for: ->(record) { permission_rules_for(record) },
on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) }
)
# Turn: the orchestrator. Drives send -> stream -> recover ->
# finalize. Pass it the host's ReplyObserver factory so the gem's
# Reply state machine can bridge to your Turbo broadcasts.
Opencode::Turn.new(
message: assistant_message,
subject: conversation,
query_text: user_message.content,
client: client,
session_for: ->(*) { session },
observer_factory: ->(message) { ReplyStream.new(message: message) },
system_context: build_system_context(conversation),
agent_name: "default",
tracer: Opencode::Tracer.new(prefix: "opencode."),
on_turn_finished: ->(result) {
Rails.logger.info("turn finished status=#{result.status} cost=#{result.cost}")
}
).call
rescue StandardError => e
Opencode::ErrorReporter.report(e, severity: :error,
context: { message_id: assistant_message.id, conversation_id: conversation.id })
assistant_message.update!(status: :error, content: "Sorry, something went wrong.")
end
private
def permission_rules_for(conversation)
# Per-product permissions. The shape mirrors what
# opencode-ruby's Client#create_session expects in `permissions:`.
[
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" },
{ type: "edit", action: "deny", path: "*" } # default deny outside sandbox
]
end
def build_system_context(conversation)
# System prompt context the agent gets. Your app probably already
# has helpers for this; the gem doesn't impose a shape.
{
user_name: conversation.user.name,
conversation_id: conversation.id,
sandbox_path: "/sandbox/#{conversation.id}"
}
end
end
# ----------------------------------------------------------------------
# 3. app/services/reply_stream.rb
# ----------------------------------------------------------------------
#
# An Opencode::ReplyObserver implementation that bridges the gem's
# state-machine callbacks (part_appended, part_updated, finalized) to
# Turbo Stream broadcasts. The gem ships the protocol; the host owns
# the rendering.
#
# This is one of three places hosts customize: the renderer of a
# tool-call part. The other two are permission_rules_for and
# build_system_context above.
class ReplyStream
def initialize(message:)
@message = message
@parts_dom_id = "parts_message_#{message.id}"
end
# Called every time a new part shows up in the reply.
def on_part_appended(part)
Turbo::StreamsChannel.broadcast_append_to(
@message.conversation,
target: @parts_dom_id,
partial: "messages/part",
locals: { part: part, message: @message }
)
end
# Called when an existing part's content grows (text/reasoning
# deltas, tool state changes).
def on_part_updated(part, _index)
Turbo::StreamsChannel.broadcast_update_to(
@message.conversation,
target: "part_#{part['id']}_message_#{@message.id}",
partial: "messages/part",
locals: { part: part, message: @message }
)
end
# Called when the turn finalizes. Use this to swap "Thinking…"
# placeholders, update message status indicators, etc.
def on_finalized(reply_result)
Turbo::StreamsChannel.broadcast_update_to(
@message.conversation,
target: "message_#{@message.id}_status",
partial: "messages/status",
locals: { message: @message, result: reply_result }
)
end
# Optional: called when the gem catches an exception mid-stream and
# has done its own recovery (recreated session, retried, etc.). Use
# this for a brief transient banner in the UI.
def on_error(message, severity:)
Turbo::StreamsChannel.broadcast_replace_to(
@message.conversation,
target: "message_#{@message.id}_status",
html: %(<div class="banner banner--warn">#{message}</div>)
)
end
end
# ----------------------------------------------------------------------
# That's it. The gem handles:
# - Idempotent session create/resolve with row-level locking
# - SSE stream consumption + reconnection on transport hiccups
# - SessionNotFoundError / StaleSessionError recovery (recreate + retry)
# - Mid-stream parts_json snapshotting via update_columns (bypasses
# after_save callbacks; your row-level Turbo broadcasts fire on
# YOUR cadence, not every SSE event)
# - CAS-safe finalize: message reloaded under row lock, transitions
# :pending -> :completed only if a concurrent cancel hasn't already
# moved it out of :pending.
# - Cost + token extraction from the final exchange
# - Artifact pipeline (MessageArtifacts.attach_from) with optional
# transforms (host-rendered HTML, JSON-to-PDF, etc.)
#
# Your job is the wiring. About 80 lines of Ruby gets you a production-
# grade chat agent.

View File

@@ -17,7 +17,7 @@ require "active_support/core_ext/string/inflections" # demodulize, underscore, c
require "active_support/core_ext/string/filters" # squish, truncate
require "active_support/core_ext/numeric/time" # 2.seconds, 5.minutes, etc.
require_relative "opencode/rails/version"
require_relative "opencode/rails_version"
require_relative "opencode/error_reporter"
# Tier 4 leaves (no deps on other rails-gem files)

View File

@@ -9,10 +9,10 @@ module Opencode
# - Opencode::Exchange.tool_artifacts — content lives inside a tool
# call's input/metadata (write tool).
# - Opencode::SandboxFile#as_artifact — identity conversion of a
# sandbox-resident file (the default path for Blackline + Raven).
# sandbox-resident file (the default identity path).
#
# Transforms also return Artifacts; that's why FlightResultsTransform
# returns one with the host-rendered HTML + trust metadata stamp.
# Transforms also return Artifacts — e.g. a host-rendered HTML
# artifact carrying a trust-metadata stamp.
#
# An Artifact knows how to attach itself to a message, idempotently:
# it consults `message.artifacts` to skip if its filename is already

View File

@@ -65,13 +65,13 @@ module Opencode
eligible = file_entries.reject { |e| e[:type] == "delete" }
next if eligible.empty?
Opencode::Instrumentation.instrument("opencode.apply_patch.artifacts_dropped",
Opencode::Instrumentation.notify("opencode.apply_patch.artifacts_dropped",
file_count: eligible.size,
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
message_id: part[:messageID],
session_id: part[:sessionID],
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
"extraction requires sandbox-read which is not yet wired into ResponseParser") { }
"extraction requires sandbox-read which is not yet wired into ResponseParser")
end
end
end

View File

@@ -11,9 +11,9 @@ module Opencode
# 1. A previous job retry attached the destination filename via the
# tool-extracted path (the agent wrote a file with that name and
# it landed before the trusted render did).
# 2. A pre-substrate code path persisted an agent-authored HTML file
# with the destination filename (the historical AIGL exploit
# surface that motivated the trust boundary in the first place).
# 2. A pre-substrate code path persisted an agent-authored file
# under the destination filename the same-name stored-XSS
# attack the trust boundary exists to prevent.
# 3. A previous transform version stamped different metadata and the
# trust check now correctly rejects it.
#

View File

@@ -9,7 +9,7 @@ module Opencode
#
# Two-line usage:
#
# Opencode::MessageArtifacts.new(message: m, feature: "blackline", transforms: [])
# Opencode::MessageArtifacts.new(message: m, feature: "chat", transforms: [])
# .attach_from(exchange: exchange, sandbox: sandbox)
#
# All four phases (tool extract, transform routing, impostor purge,
@@ -24,15 +24,14 @@ module Opencode
MAX_SANDBOX_ARTIFACTS = 20
# default_attach values:
# :all — Blackline/Raven default. Every safe sandbox file that
# no transform claims falls through to identity attach.
# The agent's `write` outputs are final document bytes the
# host serves back unchanged.
# :none — AIGL. The agent's sandbox is full of internal working
# scratch (notes.md, map.md, timeline.md) plus the one
# file the transform claims (flight-results.json). Only
# transform-claimed files attach; everything else stays
# agent-internal.
# :all — every safe sandbox file that no transform claims falls
# through to identity attach. Use when the agent's `write`
# outputs are final document bytes the host serves back
# unchanged.
# :none — only transform-claimed files attach; everything else stays
# agent-internal. Use when the agent's sandbox is full of
# working scratch the user shouldn't see, and only specific
# filenames (claimed by transforms) become artifacts.
def initialize(message:, feature:, transforms: [], default_attach: :all,
max_sandbox_files: MAX_SANDBOX_ARTIFACTS)
@message = message
@@ -82,11 +81,10 @@ module Opencode
if (transform = transforms.find { |t| t.applies_to?(file) })
attached += 1 if apply_transform(transform, file)
elsif default_attach == :all
# Default identity path. Blackline/Raven default — every safe
# sandbox file that no transform claims attaches as-is. AIGL
# passes default_attach: :none so non-transform files (the
# agent's notes.md / map.md / timeline.md scratch) don't
# auto-attach.
# Default identity path: every safe sandbox file that no
# transform claims attaches as-is. Callers that want the
# opposite (only transform-claimed files attach) construct
# MessageArtifacts with default_attach: :none.
attached += 1 if file.as_artifact.attach_to(message)
end
end

View File

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

View File

@@ -32,9 +32,9 @@ module Opencode
# Yields SandboxFile values for every file in the sandbox that
# passes its own #safe? predicate AND was modified after the cutoff.
# When `after:` is nil (callers without a user_message handle, e.g.
# AIGL on certain finalize paths), no mtime filter is applied —
# only safety + filetype.
# When `after:` is nil (callers without a user_message handle
# e.g. finalize paths that scan the whole sandbox), no mtime filter
# is applied — only safety + filetype.
def files(after: nil)
return enum_for(:files, after: after) unless block_given?
return unless exists?

View File

@@ -68,8 +68,8 @@ module Opencode
# Identity conversion: this sandbox file → an Artifact carrying the
# file's own bytes. Used by the substrate's default (non-transform)
# path for Blackline + Raven, whose agents write document bytes
# directly to the sandbox and expect them attached unchanged.
# path, where the agent writes document bytes directly to the
# sandbox and the host serves them back unchanged.
def as_artifact
Artifact.new(
filename: basename,

View File

@@ -3,13 +3,12 @@
module Opencode
# Owns the lifecycle of an OpenCode session against a domain record.
#
# Three near-identical implementations of this lifecycle existed on
# Blackline::Conversation, Raven::Conversation, and AIGL::Trip. Each
# had subtle differences (Blackline and Raven didn't take a row-level
# lock; AIGL did). Sandi Metz flagged the shotgun surgery in the
# architectural review — a change to the lifecycle had to be made in
# three places that looked alike but disagreed on locking. This PORO
# is the consolidated role.
# Consolidates a lifecycle that's easy to get subtly wrong: a host
# with N conversational products tends to grow N near-identical
# session-management code paths that drift on the details (which
# one took a row-level lock? which one swallowed teardown errors?).
# Single PORO, one place to change, no shotgun surgery when the
# protocol evolves.
#
# Usage:
#
@@ -23,11 +22,10 @@ module Opencode
# service.abort!(client) # best-effort upstream abort
#
# The permissions_for: callable receives the record at mint! time and
# returns the permissions array for client.create_session. Product-
# specific scoping (e.g. AIGL's workspace_key/trip_id branching) lives
# returns the permissions array for client.create_session. Per-product
# scoping (e.g. a record's workspace_key or tenant_id branching) lives
# in the caller's lambda, not in this class — that keeps Session free
# of any reference to permission-building helpers and preserves the
# rails-tier -> containers-tier boundary the design doc locks in.
# of any reference to permission-building helpers.
#
# The on_error: callable is invoked when abort! catches an
# Opencode::Error during teardown. Callers wire their own observability

View File

@@ -9,8 +9,8 @@ module Opencode
# icon identifier.
#
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
# so Blackline views, AIGL views, and any future OpenCode-backed
# feature can render tool calls consistently.
# so any view that renders OpenCode tool calls — across whatever
# products the host runs — can do so consistently.
#
# ## Data shape (Opencode::Reply writes this into `parts_json`)
#

View File

@@ -6,13 +6,16 @@ module Opencode
# agent wrote" and "bytes the host signs and attaches."
#
# The default substrate path is identity: any sandbox file the
# allowlist accepts gets attached as-is. Blackline and Raven use
# the default — their agents `write` final document bytes the host
# serves back unchanged. AIGL's contract is structurally different:
# the agent writes JSON, the **host** must render that JSON into
# trusted HTML before attaching, because the resulting HTML gets
# served inline from the app origin and an agent-written filename
# can't be permitted as stored-XSS.
# allowlist accepts gets attached as-is. This works when the agent
# writes the final document bytes itself and the host just serves
# them back unchanged.
#
# Subclass Transform when the contract is structurally different:
# the agent writes raw data (e.g. JSON), and the **host** must
# render that data into trusted output (e.g. HTML) before attaching.
# The split matters when the resulting bytes get served inline from
# your app origin — an agent-written filename can't be permitted as
# stored-XSS, so a Transform draws the trust boundary.
#
# Subclass hooks (override these — none have a generic default
# that's safe to inherit):

View File

@@ -36,8 +36,8 @@ module Opencode
# smallest honest shape.
#
# Composition over inheritance: every product-specific concern is a
# collaborator passed in. Turn never sees Blackline, Raven, or AIGL by
# name.
# collaborator passed in. Turn never sees any specific product by
# name — the orchestration shape is uniform.
#
# Collaborators
# -------------
@@ -50,7 +50,7 @@ module Opencode
#
# observer_factory callable: ->(message) returning an observer that
# responds to #watch(reply). Concretely:
# ->(message) { Blackline::ReplyStream.new(...) }.
# ->(message) { MyApp::ReplyStream.new(message: message) }.
#
# system_context callable: ->(subject) -> String system prompt.
#

View File

@@ -1,12 +1,12 @@
# frozen_string_literal: true
require_relative "lib/opencode/rails/version"
require_relative "lib/opencode/rails_version"
Gem::Specification.new do |spec|
spec.name = "opencode-rails"
spec.version = Opencode::RAILS_VERSION
spec.authors = ["Ajay Krishnan"]
spec.email = ["ajay@krishnan.ca"]
spec.email = ["opencode-rails@ajay.to"]
spec.summary = "Production-grade Rails integration for OpenCode."
spec.description = <<~DESC
@@ -19,22 +19,25 @@ Gem::Specification.new do |spec|
production-grade OpenCode streaming without rolling your own
boilerplate.
DESC
spec.homepage = "https://gitea.krishnan.ca/ajaynomics/opencode-rails"
spec.homepage = "https://github.com/ajaynomics/opencode-rails"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["changelog_uri"] = "#{spec.homepage}/src/branch/main/CHANGELOG.md"
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
spec.files = Dir.glob("lib/**/*.rb") +
Dir.glob("examples/**/*.rb") +
%w[README.md LICENSE CHANGELOG.md opencode-rails.gemspec]
spec.require_paths = ["lib"]
# The opencode-ruby gem provides the wire-level Client + Reply primitives
# this gem builds on. Versions are kept in lockstep during the alpha
# phase; will relax to a looser pessimistic pin once both gems stabilize.
spec.add_runtime_dependency "opencode-ruby", "~> 0.0.1.alpha1"
# this gem builds on. During alpha both gems evolve in lockstep — we pin
# exactly (= not ~>) so that consumers always pick the version this gem
# was tested against. Bump to alpha2 when the paired release ships.
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha2"
# Rails sub-libraries used at runtime. Depending on these individually
# (instead of the `rails` umbrella) avoids forcing host apps to load

View File

@@ -4,8 +4,8 @@ require "test_helper"
# Smoke test for Opencode::Artifact — verifies the value-object surface
# (filename/content/content_type readers). Behavioral tests around how
# the host's ActiveStorage-backed AIGL::Trip/etc. records build artifact
# collections live in the host's test suite.
# the host's ActiveStorage-backed records build artifact collections
# live in the host's test suite.
class Opencode::ArtifactTest < Minitest::Test
def test_value_object_readers
artifact = Opencode::Artifact.new(

View File

@@ -47,4 +47,29 @@ class Opencode::ErrorReporterTest < Minitest::Test
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
assert invoked, "Adapter should be invoked even with no kwargs"
end
def test_adapter_exceptions_propagate
# If the host's adapter itself raises (Honeybadger HTTP failure,
# Sentry quota error, etc.) the gem must propagate — silently
# swallowing the adapter's own errors would hide an outage from
# operators who think their error tracker is healthy.
Opencode::ErrorReporter.adapter = ->(_error, **_opts) {
raise StandardError, "adapter blew up"
}
raised = assert_raises(StandardError) do
Opencode::ErrorReporter.report(RuntimeError.new("original"))
end
assert_equal "adapter blew up", raised.message
end
def test_report_returns_adapter_return_value
# Useful for hosts wanting Rails.error.report's standard return
# (the error itself). Verifies the call shape doesn't transform it.
sentinel = Object.new
Opencode::ErrorReporter.adapter = ->(_error, **_opts) { sentinel }
result = Opencode::ErrorReporter.report(StandardError.new("x"))
assert_same sentinel, result
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Impostor. Wraps an ActiveStorage
# attachment that's been replaced by a Transform-rendered artifact.
# Behavioral tests against real ActiveStorage::Attachment live in
# the host application.
class Opencode::ImpostorTest < Minitest::Test
def test_initialize_takes_attachment_keyword
params = Opencode::Impostor.instance_method(:initialize).parameters
assert_includes params, [ :keyreq, :attachment ],
"Impostor must require an attachment: keyword (ActiveStorage::Attachment-like)"
end
def test_public_api
assert_equal %i[filename purge!].sort,
Opencode::Impostor.instance_methods(false).sort
end
def test_filename_delegates_to_attachment
attachment_double = Struct.new(:filename).new("legacy.html")
impostor = Opencode::Impostor.new(attachment: attachment_double)
assert_equal "legacy.html", impostor.filename
end
end

View File

@@ -36,16 +36,24 @@ class Opencode::LoadingTest < Minitest::Test
end
end
# We check via path match on both directory ("/opencode-rails/") and
# installed-gem name ("/opencode-rails-VERSION/") so the assertion is
# robust to either a sibling-repo dev setup or a bundle-resolved gem
# install.
GEM_PATH_PATTERN = ->(name) { %r{/#{Regexp.escape(name)}[-/]} }
def test_session_constant_points_at_this_gem
location = Opencode::Session.instance_method(:initialize).source_location.first
assert_match %r{/opencode-rails/}, location,
assert_match GEM_PATH_PATTERN.call("opencode-rails"), location,
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
end
def test_client_constant_points_at_opencode_ruby
location = Opencode::Client.instance_method(:initialize).source_location.first
assert_match %r{/opencode-ruby/}, location,
assert_match GEM_PATH_PATTERN.call("opencode-ruby"), location,
"Expected Opencode::Client to come from opencode-ruby, got: #{location}"
refute_match GEM_PATH_PATTERN.call("opencode-rails"), location,
"Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)"
end
def test_version_constant

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::MessageArtifacts (the idempotent
# ActiveStorage-backed artifact attachment pipeline). Behavioral
# coverage — including ActiveStorage attachment, Transform application,
# error reporting via Opencode::ErrorReporter — lives in the host app.
class Opencode::MessageArtifactsTest < Minitest::Test
def test_initialize_takes_message_feature_and_optional_transforms
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
assert_includes by_kind[:keyreq], :message,
"MessageArtifacts must require a message: keyword"
assert_includes by_kind[:keyreq], :feature,
"MessageArtifacts must require a feature: keyword (used in error reports)"
assert_includes by_kind[:key] || [], :transforms,
"MessageArtifacts must accept an optional transforms: keyword"
end
def test_public_api_is_attach_from
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
"MessageArtifacts's only public verb is #attach_from"
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
# Smoke test for Opencode::SandboxFile: instantiate against a real
# pathname, verify basename/size/content/content_type readers and the
# identity conversion to Opencode::Artifact via #as_artifact.
class Opencode::SandboxFileTest < Minitest::Test
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
@path = File.join(@tmpdir, "notes.md")
File.write(@path, "# hello\nworld\n")
# SandboxFile uses `start_with?` against this prefix to detect path
# escape; it expects a String with trailing separator so that
# /sandbox-1 doesn't false-positive on /sandbox-10/foo.
@sandbox_prefix = File.join(@tmpdir, "")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_basic_readers
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
assert_equal "notes.md", file.basename
assert file.size.positive?
assert_equal "# hello\nworld\n", file.content
assert file.safe?, "small text file inside sandbox should be safe"
end
def test_content_type_detection_via_marcel
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
# Marcel detects .md as text/markdown.
assert_match(/markdown|text/, file.content_type)
end
def test_safe_rejects_files_over_size_cap
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 5
)
refute file.safe?, "file larger than max_bytes must be unsafe"
end
def test_as_artifact_returns_opencode_artifact_value
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
artifact = file.as_artifact
assert_instance_of Opencode::Artifact, artifact
assert_equal "notes.md", artifact.filename
assert_equal "# hello\nworld\n", artifact.content
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
require "marcel" # SandboxFile (yielded by Sandbox#files) needs marcel
# Smoke test for Opencode::Sandbox: instantiate against a real tmpdir,
# verify #path, #exists?, and that #files / #file return empty / nil
# when no sandbox files are present. Behavioral coverage of actual file
# enumeration lives in the host application where real per-product
# sandbox configurations exercise the path.
class Opencode::SandboxTest < Minitest::Test
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-test-")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_path_and_exists_when_directory_present
sandbox = Opencode::Sandbox.new(path: @tmpdir)
assert_equal @tmpdir, sandbox.path
assert sandbox.exists?
end
def test_exists_false_when_path_missing
sandbox = Opencode::Sandbox.new(path: File.join(@tmpdir, "missing"))
refute sandbox.exists?
end
def test_files_returns_enumerator_yielding_nothing_when_empty
sandbox = Opencode::Sandbox.new(path: @tmpdir)
# No block given => Enumerator.
assert_kind_of Enumerator, sandbox.files
assert_equal [], sandbox.files.to_a
end
def test_files_yields_sandbox_files_for_real_entries
File.write(File.join(@tmpdir, "notes.md"), "x")
File.write(File.join(@tmpdir, "map.md"), "y")
sandbox = Opencode::Sandbox.new(path: @tmpdir)
basenames = sandbox.files.map(&:basename).sort
assert_equal %w[map.md notes.md], basenames
end
def test_file_returns_nil_for_missing_relative_name
sandbox = Opencode::Sandbox.new(path: @tmpdir)
assert_nil sandbox.file("nope.txt")
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Session. Behavioral coverage (idempotent
# ensure!/recreate!/abort! with row-level locking, race-safety,
# permissions_for callable handoff) lives in the host application
# where AR fixtures + a real ActiveRecord row exist.
class Opencode::SessionTest < Minitest::Test
def test_initialize_takes_record_and_two_keyword_callables
params = Opencode::Session.instance_method(:initialize).parameters
assert_includes params, [ :req, :record ],
"Session must take a positional record (an AR row with #with_lock, #title, etc.)"
assert_includes params, [ :keyreq, :permissions_for ],
"Session must require a permissions_for: callable (host-injected per-product permissions)"
assert_includes params, [ :key, :on_error ],
"Session must accept an optional on_error: callable for adapter-style error reporting"
end
def test_public_api_is_ensure_recreate_abort_just_created
methods = Opencode::Session.instance_methods(false).sort
assert_equal %i[abort! ensure! just_created? recreate!].sort, methods.sort,
"Session's public surface should be exactly: ensure!/recreate!/abort!/just_created?. " \
"Found: #{methods.inspect}"
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
require "test_helper"
# Smoke tests for Opencode::ToolDisplay — the view-model that converts
# raw tool-part hashes into Turbo-Stream-friendly props. We exercise
# the predicate surface for the canonical 'read' tool plus the
# unknown-tool fallback. Exhaustive per-tool render tests live in the
# host where the renderer templates are exercised.
class Opencode::ToolDisplayTest < Minitest::Test
def test_known_read_tool_canonicalization
display = Opencode::ToolDisplay.new(
"type" => "tool", "tool" => "read", "status" => "completed",
"input" => { "filePath" => "/sandbox/notes.md" }
)
assert_equal "read", display.canonical_tool
assert display.known?
assert display.completed?
assert display.terminal?
refute display.errored?
refute display.in_flight?
end
def test_running_status
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "read", "status" => "running")
assert display.in_flight?
refute display.terminal?
refute display.completed?
end
def test_errored_status
display = Opencode::ToolDisplay.new(
"type" => "tool", "tool" => "edit", "status" => "error", "error" => "permission denied"
)
assert display.errored?
assert display.terminal?
refute display.completed?
end
def test_unknown_tool_falls_back_gracefully
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "wat", "status" => "completed")
refute display.known?,
"Unknown tools must not claim to be known — host renderer dispatches a fallback view"
refute_nil display.canonical_tool,
"Unknown tools still need a canonical_tool so DOM ids stay stable"
end
def test_nil_part_initializes_safely
# ToolDisplay tolerates a nil part because callers sometimes pass
# message.parts_json entries that aren't tool parts.
display = Opencode::ToolDisplay.new(nil)
refute display.known?
end
end

View File

@@ -0,0 +1,69 @@
# frozen_string_literal: true
require "test_helper"
# Smoke test for Opencode::Transform — the base class for
# content-rewriting transforms. It is intentionally abstract:
# #source_filename, #destination_filename, and #render all raise
# NotImplementedError. Subclasses (host-side) provide the meat.
# These tests document the abstract contract.
class Opencode::TransformTest < Minitest::Test
def test_source_filename_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.source_filename }
assert_match(/must implement #source_filename/, err.message)
end
def test_destination_filename_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.destination_filename }
assert_match(/must implement #destination_filename/, err.message)
end
def test_render_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.render(Object.new) }
assert_match(/must implement #render/, err.message)
end
def test_purge_impostors_defaults_to_false
refute Opencode::Transform.new.purge_impostors?,
"Default #purge_impostors? must be false — conservative opt-in by subclasses"
end
# A trivial concrete subclass exercises the defaults that DO exist
# (#applies_to?, #trusted?, #owned_filenames all delegate to the
# two abstract filename methods).
class FakeTransform < Opencode::Transform
def source_filename = "agent-output.json"
def destination_filename = "rendered.html"
end
Attachment = Struct.new(:filename, keyword_init: true)
Basenamed = Struct.new(:basename, keyword_init: true)
def test_applies_to_matches_source_filename_by_default
transform = FakeTransform.new
matching = Basenamed.new(basename: "agent-output.json")
other = Basenamed.new(basename: "something-else.json")
assert transform.applies_to?(matching)
refute transform.applies_to?(other)
end
def test_trusted_matches_destination_filename_by_default
transform = FakeTransform.new
trusted = Attachment.new(filename: "rendered.html")
untrusted = Attachment.new(filename: "agent-output.json")
assert transform.trusted?(trusted)
refute transform.trusted?(untrusted)
end
def test_owned_filenames_is_source_and_destination
assert_equal %w[agent-output.json rendered.html],
FakeTransform.new.owned_filenames
end
def test_error_is_a_subclass_of_standarderror
assert_operator Opencode::Transform::Error, :<, StandardError,
"Transform::Error must be rescuable by `rescue StandardError`"
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
# Result value object. Behavioral coverage (the full send -> stream ->
# recover -> finalize loop) lives in the host application — Turn needs
# an Opencode::Client, an AR Message, a subject record, etc., which are
# all integration-level concerns.
class Opencode::TurnTest < Minitest::Test
REQUIRED_INIT_KEYS = %i[
message subject query_text client session_for observer_factory
system_context agent_name tracer
].freeze
OPTIONAL_INIT_KEYS = %i[
on_finalized on_turn_finished on_activity_tick
empty_stream_retry_delay final_exchange_timeout
final_exchange_retry_delay error_fallback_content error_feature
].freeze
def test_required_keyword_arguments
params = Opencode::Turn.instance_method(:initialize).parameters
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
assert_equal REQUIRED_INIT_KEYS.sort, required,
"Turn's required keyword args drifted. Expected: #{REQUIRED_INIT_KEYS.sort}, got: #{required}"
end
def test_optional_keyword_arguments_match_documented_surface
params = Opencode::Turn.instance_method(:initialize).parameters
optional = params.select { |kind, _| kind == :key }.map(&:last).sort
assert_equal OPTIONAL_INIT_KEYS.sort, optional,
"Turn's optional keyword args drifted. Expected: #{OPTIONAL_INIT_KEYS.sort}, got: #{optional}"
end
def test_public_surface_is_call_only
# Turn is an orchestrator; the only public verb is #call. Everything
# else is internal. Locking this prevents helpers from accidentally
# bleeding into the public API.
assert_equal [ :call ], Opencode::Turn.instance_methods(false)
end
def test_result_is_a_value_object_with_status_predicates
fake_message = Struct.new(:cost, :input_tokens, :output_tokens, keyword_init: true).new(
cost: 0.012, input_tokens: 100, output_tokens: 50
)
result = Opencode::Turn::Result.new(
status: :completed, message: fake_message, duration_ms: 1234
)
assert result.completed?
refute result.cancelled?
refute result.errored?
refute result.failed?
assert_equal 1234, result.duration_ms
assert_equal 0.012, result.cost
assert_equal 100, result.input_tokens
assert_equal 50, result.output_tokens
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
# Smoke test for Opencode::UploadedFilesPrompt. The sandbox_path:
# inversion (per D14 in the design doc) means we can exercise the
# happy path against a tmpdir without needing Rails / AR / ActiveStorage
# fixtures. Behavioral tests covering ActiveStorage attached_blob
# enumeration live in the host application.
class Opencode::UploadedFilesPromptTest < Minitest::Test
# Minimal stub for a user message: #content (the raw user text) and
# #files (an ActiveStorage-like collection with #attached?). Real
# behavior is exercised in the host's test suite.
FakeMessage = Struct.new(:content, :files, keyword_init: true)
EmptyFiles = Struct.new(:attached) do
def attached? = attached
end
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_initialize_takes_three_required_keywords
params = Opencode::UploadedFilesPrompt.instance_method(:initialize).parameters
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
assert_equal %i[sandbox_name_for sandbox_path user_message], required,
"UploadedFilesPrompt requires user_message:, sandbox_path:, sandbox_name_for:"
end
def test_text_returns_raw_content_when_no_files_attached
message = FakeMessage.new(content: "hello world", files: EmptyFiles.new(false))
prompt = Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(file) { file.filename.to_s }
)
assert_equal "hello world", prompt.text,
"No attached files => text is the raw user content"
assert_equal({}, prompt.sandbox_file_names,
"No attached files => sandbox_file_names map stays empty")
end
def test_public_surface
assert_equal %i[sandbox_file_names text].sort,
Opencode::UploadedFilesPrompt.instance_methods(false).sort
end
end