Review: opencode-rails v0.0.1.alpha2 full gem #1

Closed
ajaynomics wants to merge 1 commits from review/v0.0.1.alpha2-full-gem into review-base
37 changed files with 3179 additions and 1 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'

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
*.gem
.bundle/
Gemfile.lock
pkg/
tmp/
.ruby-version
.byebug_history
coverage/

50
CHANGELOG.md Normal file
View File

@@ -0,0 +1,50 @@
# 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 Rails app where these objects shipped as in-tree library code before being carved out into a standalone gem.
**Includes:**
- `Opencode::Session` — AR-coupled, row-level-locked session lifecycle (`ensure!`, `recreate!`, `abort!`)
- `Opencode::Turn` — orchestrator covering send → stream → recover → finalize, with CAS-safe message terminal-state transitions
- `Opencode::Exchange` — domain object over a turn's message array; emits `opencode.apply_patch.artifacts_dropped` when post-write file content is unavailable
- `Opencode::Artifact` — value-object (filename + content + content_type + trust metadata), idempotent attach
- `Opencode::MessageArtifacts` — ActiveStorage-aware artifact attachment pipeline with transform support
- `Opencode::Sandbox` — disk-backed sandbox reader, returns `Artifact` list
- `Opencode::SandboxFile` — single-file value object (pathname → bytes/content-type)
- `Opencode::Transform` — base class for content-rewriting transforms
- `Opencode::Impostor` — ActiveStorage download/upload round-trip helper
- `Opencode::UploadedFilesPrompt` — user-prompt prefix builder listing uploaded files, sandbox-path inverted (injection-based, no `Opencode::Permissions` reference)
- `Opencode::ToolDisplay` — view-model for tool-call hashes (Turbo Stream-friendly)
- `Opencode::ErrorReporter` — pluggable adapter mirroring the `Opencode::Instrumentation` pattern
**Runtime dependencies:**
- `opencode-ruby ~> 0.0.1.alpha1` (wire client + Reply state machine)
- `activerecord >= 7.1, < 9.0`
- `activestorage >= 7.1, < 9.0`
- `activesupport >= 7.1, < 9.0`
**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. 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>.

3
Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gemspec

122
README.md
View File

@@ -1,3 +1,123 @@
# opencode-rails
Production-grade Rails integration for OpenCode. ActiveRecord-aware session lifecycle, Turbo-friendly streaming, mid-stream snapshot pattern.
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.
## Why this gem exists
`opencode-ruby` gives you a clean Net::HTTP + SSE client. To turn it into something a Rails app can ship, you need:
1. **Session lifecycle** — idempotent create-or-resolve with row-level locking so concurrent jobs don't double-mint sessions; recreation for stale-session recovery; best-effort upstream abort on teardown.
2. **Turn orchestration** — drive `send_message_async`, stream events into `Opencode::Reply`, recover from `SessionNotFoundError` / `StaleSessionError` by recreating + retrying, fetch the final exchange for cost/artifact extraction, finalize the persisted message via CAS against `:pending`.
3. **Mid-stream snapshot**`update_columns` writes to bypass AR callbacks so app-level Turbo broadcasts fire on rate-limited intervals, not every SSE event.
4. **Artifact pipeline** — extract files written by the agent (via the `write` tool's metadata, or by reading from a sandbox path), attach them to the assistant message via ActiveStorage, transform host-rendered artifacts (flight results HTML, etc.) before persisting.
`opencode-rails` ships these as named, single-responsibility objects so you wire them up instead of re-implementing them.
## Install
```ruby
# Gemfile
gem "opencode-ruby" # wire client + Reply state machine
gem "opencode-rails" # AR-coupled session/turn/artifact stack
```
```bash
bundle install
```
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
## Quickstart
```ruby
# config/initializers/opencode.rb
Opencode::Instrumentation.adapter = ->(name, payload, &blk) {
ActiveSupport::Notifications.instrument(name, payload, &blk)
}
Opencode::ErrorReporter.adapter = ->(error, **opts) {
Rails.error.report(error, **opts) if Rails.respond_to?(:error)
}
```
```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"])
session = Opencode::Session.new(
conversation,
permissions_for: ->(record) { permission_rules_for(record) },
on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) }
)
Opencode::Turn.new(
message: assistant_message,
subject: conversation,
query_text: user_message.content,
client: client,
session: session,
on_turn_finished: ->(result) {
# result.status #=> :completed | :error | :cancelled
# result.message #=> the AR row (reloaded)
# result.duration_ms
}
).call
end
private
def permission_rules_for(conversation)
[
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" }
]
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?`.
## What you get
| Constant | Role |
|---|---|
| `Opencode::Session` | Idempotent ensure!/recreate!/abort! with row-level locking |
| `Opencode::Turn` | End-to-end orchestrator: ensure session → send → stream → recover → finalize |
| `Opencode::Exchange` | Domain object over a turn's message array; owns artifact extraction |
| `Opencode::Artifact` | Value object: filename + bytes + content_type + trust metadata |
| `Opencode::MessageArtifacts` | Idempotent attachment pipeline (ActiveStorage-aware, transform-applying) |
| `Opencode::Sandbox` | Reads written files from disk, returns Artifact list |
| `Opencode::SandboxFile` | Single-file value: pathname → bytes/content-type/identity Artifact |
| `Opencode::Transform` | Base class for content-rewriting transforms (e.g. host-rendered HTML) |
| `Opencode::Impostor` | ActiveStorage download/upload helper that round-trips bytes |
| `Opencode::UploadedFilesPrompt` | Builds the user-prompt prefix listing uploaded files |
| `Opencode::ToolDisplay` | View-model converting tool-call hashes to Turbo-friendly props |
| `Opencode::ErrorReporter` | Pluggable adapter for routing swallowed errors |
Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Tracer`, `Prompts`, `ResponseParser`, `ToolPart`, `PartSource`, `Todo`, `Instrumentation`, the error hierarchy.
## Instrumentation + error reporting
The gem emits events through `Opencode::Instrumentation.instrument(name, payload, &blk)` and reports swallowed errors through `Opencode::ErrorReporter.report(error, **opts)`. Both are no-ops by default. Wire your host's emitter / reporter in an initializer (see Quickstart above).
Events emitted (non-exhaustive):
- `opencode.turn.started`, `opencode.turn.finished`
- `opencode.stream.completed`, `opencode.stream.interrupted`
- `opencode.session.created`, `opencode.session.recreated`
- `opencode.apply_patch.artifacts_dropped`
- `opencode.response.upstream_error`
Subscribe via `ActiveSupport::Notifications.subscribe("opencode.*")` once you've wired the adapter.
## Position
`opencode-rails` is for Rails apps that want production-grade OpenCode streaming without reimplementing the orchestration boilerplate. If you want only the wire client, use `opencode-ruby` directly. If you want every endpoint with generated types, use [`opencode_client`](https://rubygems.org/gems/opencode_client) (Eric Guo's auto-generated gem).
## License
MIT. See [LICENSE](LICENSE).

12
Rakefile Normal file
View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
require "bundler/gem_tasks"
require "rake/testtask"
Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/**/*_test.rb"]
end
task default: :test

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.

39
lib/opencode-rails.rb Normal file
View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
# opencode-rails — Production Rails integration for OpenCode.
#
# Loads the wire-level primitives from opencode-ruby, then layers on the
# AR-coupled session/turn/artifact stack. Caller-facing namespace stays
# flat at `Opencode::*` (no `Opencode::Rails::Session` etc.) so this gem
# can drop into any app that already uses opencode-ruby with zero
# rename work.
require "opencode-ruby"
require "active_support/core_ext/object/blank" # blank?, present?, presence
require "active_support/core_ext/object/try"
require "active_support/core_ext/hash/keys" # deep_stringify_keys, deep_symbolize_keys
require "active_support/core_ext/string/inflections" # demodulize, underscore, camelize
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/error_reporter"
# Tier 4 leaves (no deps on other rails-gem files)
require_relative "opencode/sandbox_file"
require_relative "opencode/sandbox"
require_relative "opencode/transform"
require_relative "opencode/impostor"
require_relative "opencode/artifact"
require_relative "opencode/message_artifacts"
require_relative "opencode/uploaded_files_prompt"
require_relative "opencode/tool_display"
require_relative "opencode/exchange"
# Tier 3 (depend on the leaves above)
require_relative "opencode/session"
require_relative "opencode/turn"
module Opencode
end

63
lib/opencode/artifact.rb Normal file
View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true
module Opencode
# A file the host wants to attach to an assistant message: filename,
# content bytes, MIME type, and an optional trust-metadata hash.
#
# Artifacts come from two places in the substrate:
#
# - 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 identity path).
#
# 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
# there. The attaching verb belongs to the Artifact (the noun whose
# state the verb consults), not to a separate Attacher class.
class Artifact
attr_reader :filename, :content, :content_type, :metadata
def initialize(filename:, content:, content_type:, metadata: {})
@filename = filename
@content = content
@content_type = content_type
@metadata = metadata
end
# Idempotent attach. Returns true if newly attached, false if the
# filename was already present on the message (so callers can count
# what they actually persisted vs what was already there).
def attach_to(message)
return false if already_attached_to?(message)
message.artifacts.attach(
io: StringIO.new(content),
filename: filename,
content_type: content_type,
metadata: metadata
)
true
end
def already_attached_to?(message)
message.artifacts.any? { |a| a.filename.to_s == filename }
end
def ==(other)
other.is_a?(Artifact) &&
other.filename == filename &&
other.content == content &&
other.content_type == content_type &&
other.metadata == metadata
end
alias_method :eql?, :==
def hash
[ filename, content, content_type, metadata ].hash
end
end
end

View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Opencode
# Pluggable error-reporter adapter. opencode-rails ships with zero
# dependency on Honeybadger, Sentry, Bugsnag, Rollbar, or any specific
# error-tracking library. Host apps plug their own adapter in:
#
# # config/initializers/opencode.rb
# Opencode::ErrorReporter.adapter = ->(error, **opts) {
# Rails.error.report(error, **opts)
# }
#
# When no adapter is set (default), `.report` is a silent no-op. This
# lets the gem be used outside Rails-style apps without forcing a
# dependency on any specific error reporter, while keeping the option
# to route errors anywhere the host wants when the gem ships in a
# Rails context.
#
# Semantics mirror Rails.error.report (handled:, severity:, context:)
# but the gem doesn't enforce any specific kwarg vocabulary — whatever
# the gem code passes is forwarded verbatim to the adapter.
module ErrorReporter
class << self
attr_accessor :adapter
end
# Report an error to the configured adapter, or no-op if none set.
# Returns the adapter's return value (typically the error itself for
# `Rails.error.report`).
def self.report(error, **opts)
adapter&.call(error, **opts)
end
end
end

79
lib/opencode/exchange.rb Normal file
View File

@@ -0,0 +1,79 @@
# frozen_string_literal: true
module Opencode
# The OpenCode messages produced by a single turn (the array returned
# by GET /session/:id/message and consumed by Opencode::Turn's
# recovery + finalization paths).
#
# First-class noun rather than a bare array because:
#
# - It owns the "give me the tool-produced artifacts" question, so
# callers don't reach into ResponseParser for that. The parser is
# about wire-shape extraction; the Exchange is about the domain
# concept of "what files came out of this turn."
#
# - It owns the "opencode.apply_patch.artifacts_dropped" event
# emission, keeping ResponseParser a pure module (no instrumentation
# side effects). Pure functions stay pure. The event flows through
# Opencode::Instrumentation, so hosts wire AS::Notifications /
# Rails.event / OpenTelemetry / etc. via the adapter.
class Exchange
def initialize(messages)
@messages = Array(messages)
end
# Returns Opencode::Artifact values for every file produced by a
# tool call in this exchange (currently the `write` tool; apply_patch
# is acknowledged-but-empty in v1.15+, see ResponseParser).
#
# `exclude:` filters by destination filename — used by the substrate
# to keep tool-extracted Artifacts from racing per-message transforms
# that own the same filenames.
def tool_artifacts(exclude: [])
excluded = Set.new(exclude)
raw = Opencode::ResponseParser.extract_artifacts_from_messages(@messages)
notify_drops(raw)
raw.filter_map do |file_data|
next if excluded.include?(file_data[:filename])
Artifact.new(
filename: file_data[:filename],
content: file_data[:content],
content_type: file_data[:content_type]
)
end
end
private
# ResponseParser annotates dropped apply_patch parts on the messages
# it processes (since v1.15+ wire shape carries no inline post-write
# content). The notify lives here, not in the parser, so the parser
# stays a pure function. Operators see one event per assistant
# message that contained an apply_patch tool call.
def notify_drops(_)
@messages.each do |message|
next unless message.dig(:info, :role) == "assistant"
parts = message[:parts] || []
parts.each do |part|
next unless part[:type] == "tool" && part[:tool] == "apply_patch"
next unless part.dig(:state, :status) == "completed"
file_entries = part.dig(:state, :metadata, :files) || []
eligible = file_entries.reject { |e| e[:type] == "delete" }
next if eligible.empty?
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")
end
end
end
end
end

48
lib/opencode/impostor.rb Normal file
View File

@@ -0,0 +1,48 @@
# frozen_string_literal: true
module Opencode
# An ActiveStorage::Attachment on an assistant message that uses a
# trusted Transform's destination filename but fails the transform's
# `#trusted?` predicate. In plain English: a same-named attachment
# that wasn't produced by the host-trusted renderer pipeline.
#
# Where impostors come from:
#
# 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 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.
#
# The Impostor knows how to remove itself. The orchestrator just asks
# "are there impostors of this transform on this message?" and tells
# each one to `purge!`. Purging is a verb that belongs to the
# impostor — it's the noun whose state the purge mutates.
class Impostor
# Finds impostors of `transform` on `message` — attachments whose
# filename matches the transform's destination but whose contents
# fail the transform's trust predicate.
def self.for(message:, transform:)
target = transform.destination_filename
message.artifacts
.select { |a| a.filename.to_s == target }
.reject { |a| transform.trusted?(a) }
.map { |a| new(attachment: a) }
end
def initialize(attachment:)
@attachment = attachment
end
def purge!
@attachment.purge
end
def filename
@attachment.filename.to_s
end
end
end

View File

@@ -0,0 +1,131 @@
# frozen_string_literal: true
module Opencode
# The collection of new artifacts attached to an assistant message as
# a result of one turn. The orchestrator that used to live in
# Opencode::ArtifactCollector now lives on this collection — instead
# of a "Collector" verb-class, the collection knows how to populate
# itself from sources (tool exchange, sandbox) and how to attach.
#
# Two-line usage:
#
# Opencode::MessageArtifacts.new(message: m, feature: "chat", transforms: [])
# .attach_from(exchange: exchange, sandbox: sandbox)
#
# All four phases (tool extract, transform routing, impostor purge,
# default sandbox attach) live as small named methods. The substrate
# never special-cases a product — `:feature` is only for error-report
# context, and `:transforms` (default []) is per-product policy.
#
# Idempotent under retry: `Opencode::Artifact#attach_to` already
# skips when the filename is present on the message, and the
# tool-extracted phase excludes filenames the transforms own.
class MessageArtifacts
MAX_SANDBOX_ARTIFACTS = 20
# default_attach values:
# :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
@feature = feature
@transforms = transforms
@default_attach = default_attach
@max_sandbox_files = max_sandbox_files
end
# Drains both sources and attaches. Returns self so callers can
# chain off it if they want to count what landed.
def attach_from(exchange: nil, sandbox: nil, cutoff: nil, upload_echo: [])
attach_from_exchange(exchange) if exchange
attach_from_sandbox(sandbox, cutoff: cutoff, upload_echo: upload_echo) if sandbox
self
rescue StandardError => e
report(e, action: "attach_artifacts")
self
end
private
attr_reader :message, :feature, :transforms, :max_sandbox_files, :default_attach
# Tool-produced artifacts (write tool's input content). Skip any
# filename a transform owns — those land via the sandbox path so the
# transform's trust pipeline (render + metadata stamp) is the only
# way the bytes reach the user.
def attach_from_exchange(exchange)
exchange.tool_artifacts(exclude: transform_owned_filenames).each do |artifact|
artifact.attach_to(message)
end
rescue StandardError => e
report(e, action: "attach_from_exchange")
end
def attach_from_sandbox(sandbox, cutoff:, upload_echo:)
return unless sandbox.exists?
uploaded = Set.new(upload_echo)
attached = 0
sandbox.files(after: cutoff).each do |file|
break if attached >= max_sandbox_files
next if uploaded.include?(file.basename)
if (transform = transforms.find { |t| t.applies_to?(file) })
attached += 1 if apply_transform(transform, file)
elsif default_attach == :all
# 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
rescue StandardError => e
report(e, action: "attach_from_sandbox")
end
# Returns true if a fresh trusted artifact was attached. Falsy on
# already-trusted-attached, transform-raised, or duplicate-filename.
def apply_transform(transform, file)
if transform.purge_impostors?
purged = Impostor.for(message: message, transform: transform)
if purged.any?
purged.each(&:purge!)
# ActiveStorage purges the attachment + blob, but `message.artifacts`
# holds the pre-purge collection in memory. Without resetting,
# Artifact#already_attached_to? still sees the (just-purged) row
# and shortcuts the trusted attach below.
message.artifacts.reset
end
end
return false if trusted_present?(transform)
artifact = transform.render(file)
artifact.attach_to(message)
rescue Transform::Error => e
report(e, action: "transform_#{transform.class.name.demodulize}")
false
end
def trusted_present?(transform)
message.artifacts.any? { |a| transform.trusted?(a) }
end
def transform_owned_filenames
transforms.flat_map(&:owned_filenames)
end
def report(error, action:)
Opencode::ErrorReporter.report(error, handled: true, severity: :warning,
context: { feature: feature, action: action, message_id: message.id })
end
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
# NOTE: we deliberately do NOT define `Opencode::Rails` as a module —
# host applications often have files inside the `Opencode::` namespace
# that reference top-level `::Rails.something`. Defining
# `Opencode::Rails` would shadow `::Rails` under Ruby's constant
# lookup rules (`Rails.root` would resolve to `Opencode::Rails.root`
# and raise NoMethodError).
#
# The opencode-ruby gem uses `Opencode::VERSION` for its own version.
# 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.alpha2"
end

71
lib/opencode/sandbox.rb Normal file
View File

@@ -0,0 +1,71 @@
# frozen_string_literal: true
module Opencode
# The per-user (or per-trip) sandbox directory the agent's container
# writes into. A first-class noun rather than a path-string with
# primitives sprinkled around the codebase: the Sandbox knows its
# own path, knows how to walk itself, knows what "fresh enough" means
# for a given turn, and yields SandboxFile values that carry their
# own safety predicate.
#
# Used by Opencode::MessageArtifacts. Construct one with the path,
# then ask it for `files(after:)` where `after` is the user message's
# created_at time (minus CUTOFF_SLACK). Files older than the cutoff
# are stale leftovers from a previous turn — never attached.
class Sandbox
# Two-second slack absorbs clock skew between the Rails app and the
# per-user OpenCode container. Without it, a file written by the
# container in the same wall-clock second as the user message could
# be (mtime < created_at) and get rejected.
CUTOFF_SLACK = 2.seconds
attr_reader :path
def initialize(path:, max_file_bytes: Opencode::ResponseParser::MAX_ARTIFACT_SIZE)
@path = path
@max_file_bytes = max_file_bytes
end
def exists?
path.present? && Dir.exist?(path)
end
# 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. 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?
cutoff = after && (after.to_time - CUTOFF_SLACK)
Dir.glob(File.join(path, "*")).each do |entry|
next unless File.file?(entry)
next if cutoff && File.mtime(entry) < cutoff
file = SandboxFile.new(
path: entry,
sandbox_prefix: prefix,
max_bytes: @max_file_bytes
)
next unless file.safe?
yield file
end
end
def file(basename, after: nil)
files(after: after).find { |f| f.basename == basename }
end
private
# Separator-terminated prefix so /sandbox-1 doesn't false-positive
# on /sandbox-10/foo when SandboxFile checks realpath containment.
def prefix
@prefix ||= File.join(path, "")
end
end
end

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
require "pathname"
module Opencode
# One file living inside an Opencode::Sandbox.
#
# Carries the safety predicate inline (#safe?) so the orchestrator
# doesn't have to know what "safe" means — symlink, realpath inside
# the sandbox, size cap. Carries the default identity conversion to
# Artifact (#as_artifact) so non-transform code can attach a sandbox
# file as-is without re-implementing the marcel + StringIO ceremony.
#
# mtime-cutoff freshness lives on Opencode::Sandbox#files(after:),
# not here — the file doesn't know which turn opened "after." That's
# a property of the scan, not a property of the file.
class SandboxFile
attr_reader :path, :sandbox_prefix
def initialize(path:, sandbox_prefix:, max_bytes:)
@path = path
@sandbox_prefix = sandbox_prefix
@max_bytes = max_bytes
end
def basename
File.basename(path)
end
def size
File.size(path)
end
def mtime
File.mtime(path)
end
def content
File.read(path)
end
def content_type
Marcel::MimeType.for(name: basename)
end
# Defense-in-depth on individual file paths the scan yielded:
#
# - Reject symlinks (no follow-the-link escape).
# - The resolved realpath of the path must lie inside the sandbox
# with a separator-terminated prefix so /sandbox-1 doesn't false-
# positive on /sandbox-10/foo.
# - Reject anything over the size cap (default
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
#
# The Sandbox scan filters non-files (directories, FIFOs) before
# yielding, so we don't re-check #file? here.
def safe?
return false if File.symlink?(path)
return false unless Pathname.new(path).realpath.to_s.start_with?(sandbox_prefix)
return false if size > @max_bytes
true
rescue Errno::ENOENT
# Concurrent deletion between scan-yield and safety-check — treat
# as unsafe so the orchestrator skips rather than crashing.
false
end
# Identity conversion: this sandbox file → an Artifact carrying the
# file's own bytes. Used by the substrate's default (non-transform)
# 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,
content: content,
content_type: content_type
)
end
end
end

166
lib/opencode/session.rb Normal file
View File

@@ -0,0 +1,166 @@
# frozen_string_literal: true
module Opencode
# Owns the lifecycle of an OpenCode session against a domain record.
#
# 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:
#
# service = Opencode::Session.new(
# conversation,
# permissions_for: ->(record) { permission_rules_for(record) },
# on_error: ->(e, **opts) { Rails.error.report(e, **opts) } # optional
# )
# session_id = service.ensure!(client) # idempotent create-or-resolve
# service.recreate!(client) # always create fresh
# 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. 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.
#
# The on_error: callable is invoked when abort! catches an
# Opencode::Error during teardown. Callers wire their own observability
# (Rails.error.report, OpenTelemetry, Sentry, custom logging) here.
# Defaults to nil — silently swallowing teardown errors, which matches
# the pre-inversion behaviour. Substrate has no opinion about how the
# host reports.
#
# The record must respond to:
# - #title (String) — passed as session title
# - #opencode_session_id / #opencode_session_id= — string column
# - #with_lock(&block) — ActiveRecord row-level lock
# - #update! / #reload — standard ActiveRecord
# - #id — for error reporting context
class Session
def initialize(record, permissions_for:, on_error: nil)
@record = record
@permissions_for = permissions_for
@on_error = on_error
@just_created = false
end
# True iff the most recent ensure!/recreate! actually created a
# fresh upstream session (vs resolving to an existing id).
#
# Exists so consumers can distinguish "we just minted this" from
# "we found an existing one" without poking at ActiveRecord dirty
# tracking on the record. The right object to ask is the object
# that did the work; that's this one.
def just_created?
@just_created
end
# Returns the session id for the record, creating an OpenCode session
# if none exists yet. Idempotent. Race-safe via row-level locking and
# double-check shortcuts that avoid the lock entirely when an id is
# already persisted.
def ensure!(client)
@just_created = false
return @record.opencode_session_id if @record.opencode_session_id.present?
@record.with_lock do
# Double-check inside the lock: another worker may have set the
# id between our first read and acquiring the lock.
return @record.opencode_session_id if @record.opencode_session_id.present?
mint!(client)
end
rescue ActiveRecord::RecordNotUnique
# Two workers raced past the unique-index gate. The loser reloads
# to pick up the winner's id. The winner is the one that
# just_created?; the loser sees @just_created = false.
@record.reload
@record.opencode_session_id
end
# Always creates a fresh session and overwrites the persisted id.
# Used by Opencode::Turn recovery when the upstream session has gone
# stale (StaleSessionError / SessionNotFoundError).
#
# Race semantics: two concurrent recreate! callers serialize through
# with_lock; the first mints, the second observes the freshly-minted
# id (different from its own pre-lock snapshot) and returns that
# rather than minting again. Both callers converge on one upstream
# session — no orphan leak.
def recreate!(client)
@just_created = false
pre_lock_id = @record.opencode_session_id
@record.with_lock do
# If another recreate! caller minted while we were waiting for
# the lock, the id changed under us. Treat their fresh mint as
# fresh enough for us too — recreate! returns "a session that's
# newer than what I saw at method entry", not "specifically my
# own mint".
current_id = @record.opencode_session_id
if current_id.present? && current_id != pre_lock_id
return current_id
end
mint!(client)
end
end
# Best-effort upstream abort. Swallows Opencode::Error so callers
# never have to wrap this in a rescue — aborts run inside cleanup
# paths where re-raising would mask the real cause of teardown.
def abort!(client)
return unless @record.opencode_session_id.present?
client.abort_session(@record.opencode_session_id)
rescue Opencode::Error => e
@on_error&.call(e, action: "abort_session", record_id: @record.id)
end
private
# The atomic create-and-persist unit shared by ensure! and recreate!.
#
# One operation, two failure modes:
# - client.create_session raises -> nothing to clean up, re-raise
# - update! raises RecordInvalid -> upstream session exists,
# delete it before re-raising
# so we never leak orphans
#
# Sets @just_created = true on success; callers reset to false at
# method entry so a no-op call (existing id) reports false correctly.
def mint!(client)
session_id = nil
begin
result = client.create_session(title: @record.title, permissions: @permissions_for.call(@record))
session_id = extract_session_id(result)
@record.update!(opencode_session_id: session_id)
@just_created = true
session_id
rescue ActiveRecord::RecordInvalid
safely_delete(client, session_id) if session_id
raise
end
end
# OpenCode HTTP responses use symbol keys when parsed via JSON.parse
# with symbolize_names: true (Opencode::Client) but mocks/stubs in
# tests often produce string-keyed hashes. Accept both.
def extract_session_id(result)
result[:id] || result["id"]
end
def safely_delete(client, session_id)
client.delete_session(session_id)
rescue Opencode::Error
# Best-effort cleanup; the orphan may be gone already or the
# upstream is unavailable. Either way, we already have a real
# error to raise.
end
end
end

View File

@@ -0,0 +1,423 @@
# frozen_string_literal: true
module Opencode
# A value object that wraps an OpenCode tool part (the shape produced by
# Opencode::Reply from `message.part.updated` events) and exposes the
# information a renderer needs — canonical tool name, human labels,
# target (filepath / pattern / url / command), semantic accessors for
# rich content (unified diffs, todo lists, bash output, etc.), and an
# icon identifier.
#
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
# 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`)
#
# {
# "type" => "tool",
# "tool" => "read" | "edit" | "exa_web_search_exa" | ...,
# "status" => "pending" | "running" | "completed" | "error",
# "input" => { "filePath" => ..., "content" => ..., ... },
# "title" => "..." (optional, from tool-result)
# "error" => "..." (only when status == "error")
# "metadata" => { (optional, from tool-result)
# "diff" => "...unified diff text...",
# "diagnostics" => { filePath => [LSP diagnostics] },
# "preview" => "...file preview...",
# "matches" => Integer,
# "count" => Integer,
# "output" => "...bash stdout...",
# "stdout" => "...bash stdout (legacy key)...",
# "description" => "...bash description...",
# "error" => truthy when the tool ran but returned an error,
# },
# "output" => "...raw tool output string..."
# }
#
# ## MCP prefix handling
#
# OpenCode's MCP adapter prefixes tools with the server name: Exa's
# `web_search_exa` becomes `exa_web_search_exa` on the wire (double
# suffix because Exa also names the tool with an `_exa` suffix).
# `#canonical_tool` strips known MCP prefixes so switching logic can
# treat `exa_web_search_exa` and `web_search_exa` as the same tool.
#
# ## Adding a new tool
#
# Add one row to the `TOOLS` table below — every derived concern (kind,
# gerund, icon, past-tense verb, KNOWN membership) is computed from it.
#
# ## Example
#
# display = Opencode::ToolDisplay.new(part)
# display.canonical_tool # => "edit"
# display.kind # => "Edit"
# display.gerund # => "Editing"
# display.target # => "app/models/user.rb"
# display.diff # => "@@ -1,3 +1,4 @@\n..."
# display.icon # => :pencil_square
#
class ToolDisplay
# The single source of truth for every tool we render with dedicated
# affordances. One row per tool, five columns:
#
# :kind — noun label ("Read", "Web search")
# :gerund — present-progressive phrase ("Reading", "Searching the web")
# :icon — abstract icon name the view layer maps to an SVG
# :past — past-tense verb ("Read", "Searched", "Wrote")
#
# Anything not in this table falls back to generic rendering (humanize
# the canonical name + OpenCode's title).
TOOLS = {
"read" => { kind: "Read", gerund: "Reading", icon: :document, past: "Read" },
"write" => { kind: "Write", gerund: "Writing", icon: :document_plus, past: "Wrote" },
"edit" => { kind: "Edit", gerund: "Editing", icon: :pencil_square, past: "Edited" },
"multiedit" => { kind: "Edit", gerund: "Editing", icon: :pencil_square, past: "Edited" },
"apply_patch" => { kind: "Patch", gerund: "Applying changes", icon: :pencil_square, past: "Applied changes to" },
"bash" => { kind: "Bash", gerund: "Running command", icon: :command_line, past: "Ran" },
"grep" => { kind: "Grep", gerund: "Searching files", icon: :document_magnifying_glass, past: "Searched for" },
"glob" => { kind: "Glob", gerund: "Searching files", icon: :magnifying_glass, past: "Searched for" },
"list" => { kind: "LS", gerund: "Listing files", icon: :rectangle_stack, past: "Listed" },
"ls" => { kind: "LS", gerund: "Listing files", icon: :rectangle_stack, past: "Listed" },
"webfetch" => { kind: "Fetch", gerund: "Reading web page", icon: :globe, past: "Fetched" },
"websearch" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
"codesearch" => { kind: "Code search", gerund: "Searching code", icon: :magnifying_glass, past: "Searched code for" },
"web_search_exa" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
# @zhafron/mcp-web-search exposes two tools: search_web (SearXNG meta-
# search) and fetch_url (Mozilla Readability page fetch). OpenCode
# prefixes them with the MCP server name from config.json
# (`local-web-search_`), which `canonical_tool` strips before lookup.
"search_web" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
"fetch_url" => { kind: "Fetch", gerund: "Reading web page", icon: :globe, past: "Fetched" },
"get_code_context_exa" => { kind: "Code lookup", gerund: "Looking up code", icon: :document_magnifying_glass, past: "Looked up" },
"company_research_exa" => { kind: "Company research", gerund: "Researching company", icon: :globe, past: "Researched" },
"todowrite" => { kind: "Plan", gerund: "Planning", icon: :queue_list, past: "Updated plan" },
"todoread" => { kind: "Plan", gerund: "Reading plan", icon: :queue_list, past: "Read plan" },
"task" => { kind: "Task", gerund: "Researching", icon: :robot, past: "Ran subtask" },
"skill" => { kind: "Skill", gerund: "Loading skill", icon: :sparkles, past: "Loaded skill" }
}.freeze
KNOWN = TOOLS.keys.freeze
DEFAULT_ICON = :sparkles
# MCP server prefixes to strip, paired with the tools they canonicalize
# to (for `#provider` classification). Prefixes sorted by length
# descending in case future additions overlap.
PROVIDERS = {
"exa" => { prefix: "exa_", canonical: %w[web_search_exa get_code_context_exa company_research_exa].freeze },
"brave" => { prefix: "brave_", canonical: [].freeze },
"serper" => { prefix: "serper_", canonical: [].freeze },
"tavily" => { prefix: "tavily_", canonical: [].freeze },
# The local-web-search MCP server registered in
# config/opencode/<product>/config.json. Hyphenated server name plus
# underscore separator; canonical tools are search_web and fetch_url.
"local-web-search" => { prefix: "local-web-search_", canonical: %w[search_web fetch_url].freeze }
}.freeze
MCP_PREFIXES = PROVIDERS.values.map { |p| p[:prefix] }.freeze
attr_reader :part
def initialize(part)
@part = part || {}
end
# Convenience constructor for raw OpenCode API parts (symbol keys,
# nested under `state`). Flattens into the canonical `parts_json`
# shape Reply persists.
#
# raw = { type: "tool", tool: "bash", callID: ...,
# state: { status: "running", input: {...}, title: "..." } }
# Opencode::ToolDisplay.from_raw(raw)
def self.from_raw(raw)
raw = (raw || {}).deep_stringify_keys
state = raw["state"] || {}
new(
"type" => "tool",
"tool" => raw["tool"],
"status" => state["status"],
"input" => state["input"] || {},
"output" => state["output"],
"title" => state["title"],
"error" => state["error"],
"metadata" => state["metadata"] || {}
)
end
# ----- Identity -----------------------------------------------------
def tool_name
@part["tool"].to_s
end
# Strips MCP prefixes — and, when present, the matching MCP suffix —
# so tools render cleanly regardless of how the server namespaces them.
#
# Examples:
# exa_web_search_exa → web_search_exa (KNOWN tool, preserved)
# exa_web_fetch_exa → web_fetch (not KNOWN; cleaned for display)
# exa_nonexistent → nonexistent (prefix-stripped fallback)
# brave_read → read (KNOWN after prefix strip)
#
# The double-strip handles Exa's naming convention: the MCP server
# exports tools like `web_fetch_exa`, and OpenCode prepends `exa_`,
# producing `exa_web_fetch_exa`. Stripping both yields a readable
# `web_fetch`.
def canonical_tool
name = tool_name
return name if name.empty? || KNOWN.include?(name)
MCP_PREFIXES.each do |prefix|
stripped = strip_mcp_decoration(name, prefix)
return stripped if stripped
end
name
end
def known?
KNOWN.include?(canonical_tool)
end
def icon
TOOLS.dig(canonical_tool, :icon) || DEFAULT_ICON
end
# "Read", "Edit", "Bash", or a humanized *canonical* name for unknowns.
# Humanizes `canonical_tool`, not `tool_name` — otherwise an MCP tool
# like `exa_web_fetch_exa` whose canonical form (`web_fetch`) isn't
# in TOOLS would fall back to the raw, MCP-prefixed name and display
# as "Exa web fetch exa". Using the canonical form gives the clean
# "Web fetch" label.
def kind
TOOLS.dig(canonical_tool, :kind) || canonical_tool.humanize
end
# "Reading", "Editing", "Running command", falls back to "<kind>...".
def gerund
TOOLS.dig(canonical_tool, :gerund) || "#{kind}..."
end
# ----- Status -------------------------------------------------------
def status
@part["status"].to_s
end
def pending? = status == "pending"
def running? = status == "running"
def completed? = status == "completed"
def errored? = status == "error"
def terminal? = completed? || errored?
def in_flight? = pending? || running?
# ----- Raw payloads -------------------------------------------------
def input
@part["input"].is_a?(Hash) ? @part["input"] : {}
end
def metadata
@part["metadata"].is_a?(Hash) ? @part["metadata"] : {}
end
def output
@part["output"].to_s
end
def error_text
@part["error"].to_s
end
# OpenCode-supplied title (from tool-result). Used as a fallback for
# unknown MCP tools that don't match KNOWN.
def opencode_title
@part["title"].to_s
end
# ----- Target (what the tool is operating on) ----------------------
# Returns a single-string representation of the tool's primary target,
# or nil when the tool has no meaningful single target. Callers can
# substitute display-friendly names (e.g., sandbox filenames).
def target
raw = case canonical_tool
when "read", "write", "edit", "multiedit", "apply_patch"
input["filePath"] || input["path"]
when "bash"
input["command"]
when "grep", "glob"
input["pattern"]
when "list", "ls"
input["path"]
when "webfetch", "fetch_url"
input["url"]
when "websearch", "codesearch",
"web_search_exa", "get_code_context_exa", "company_research_exa"
input["query"]
when "search_web"
# @zhafron/mcp-web-search names the argument `q`, not `query`.
input["q"]
when "task"
input["description"]
when "skill"
input["skill_name"] || input["name"]
end
raw.to_s.presence
end
# A short label combining kind + target, suitable for a one-line
# summary. Falls back to the OpenCode-supplied title for unknown MCP
# tools, then to just kind.
def title
if known?
target.present? ? "#{kind}: #{target}" : kind
else
opencode_title.presence || kind
end
end
# Past-tense "done" variant: "Read foo.rb", "Wrote contract.pdf",
# "Edited user.rb", "Ran `ls -la`". Used after completion.
def past_tense_title
if known?
verb = TOOLS.dig(canonical_tool, :past)
return kind unless verb
target.present? ? "#{verb} #{target}" : verb
else
opencode_title.presence || kind
end
end
# ----- Semantic accessors (rich content) ---------------------------
# Unified-diff text produced by the edit tool (OpenCode attaches this
# under metadata.diff). Present only after completion.
def diff
metadata["diff"].presence
end
# Sorted list of todo hashes { "content", "status", "priority", "id" }.
# Available during running (input is populated) and completed states.
# Order: in_progress first, pending next, completed last.
# Canonicalization (string keys + status hyphen→underscore) is
# delegated to Opencode::Todo so Reply and ToolDisplay can't drift.
def todos
return [] unless %w[todowrite todoread].include?(canonical_tool)
items = input["todos"]
return [] unless items.is_a?(Array)
order = { "in_progress" => 0, "pending" => 1, "completed" => 2 }
items
.select { |t| t.is_a?(Hash) }
.map { |todo| Opencode::Todo.canonicalize(todo) }
.sort_by { |todo| order[todo["status"]] || 99 }
end
# File content that the write tool is creating (lives in input.content).
def file_content
return nil unless canonical_tool == "write"
input["content"].presence
end
# Syntax-highlighting language hint based on the target filename.
# Falls back to the raw extension so unknown file types still get a
# hint their syntax-highlighter may recognize heuristically.
def file_lang
name = File.basename(target.to_s)
return nil if name.empty?
ext = File.extname(name).delete_prefix(".").downcase
LANG_BY_EXT[ext] || ext.presence
end
LANG_BY_EXT = {
"md" => "markdown", "markdown" => "markdown",
"rb" => "ruby", "rake" => "ruby",
"py" => "python",
"js" => "javascript", "mjs" => "javascript",
"ts" => "typescript", "tsx" => "typescript",
"jsx" => "jsx",
"json" => "json", "yml" => "yaml", "yaml" => "yaml",
"html" => "html", "erb" => "erb",
"css" => "css", "scss" => "scss",
"sh" => "shell", "bash" => "shell", "zsh" => "shell",
"sql" => "sql",
"go" => "go", "rs" => "rust",
"c" => "c", "h" => "c",
"cpp" => "cpp", "hpp" => "cpp",
"java" => "java", "kt" => "kotlin",
"swift" => "swift", "php" => "php",
"lua" => "lua", "toml" => "toml",
"xml" => "xml", "conf" => "shell"
}.freeze
# Bash-specific accessors.
def bash_command
return nil unless canonical_tool == "bash"
input["command"].presence
end
def bash_output
return nil unless canonical_tool == "bash"
(metadata["output"] || metadata["stdout"] || output).to_s.presence
end
def bash_description
return nil unless canonical_tool == "bash"
metadata["description"].presence
end
# Read preview (OpenCode populates metadata.preview after the read).
def read_preview
return nil unless canonical_tool == "read"
metadata["preview"].presence
end
# Grep/Glob match counts.
def match_count
case canonical_tool
when "grep" then metadata["matches"].to_i
when "glob" then metadata["count"].to_i
end
end
# ----- Provider identification for log tagging ---------------------
# Groups tools by which MCP server / built-in provides them, for
# operational logs and metrics. Adding a new provider = one row in
# PROVIDERS.
def provider
name = tool_name
canonical = canonical_tool
PROVIDERS.each do |provider_name, config|
return provider_name if name.start_with?(config[:prefix])
return provider_name if config[:canonical].include?(canonical)
end
KNOWN.include?(canonical) ? "opencode-builtin" : "unknown"
end
private
# Returns `name` with `prefix` (and the matching MCP suffix, where
# Exa double-encodes) removed, or nil when `name` doesn't carry that
# prefix. Precedence:
#
# 1. Prefer the single-stripped form when it's KNOWN
# (exa_web_search_exa → web_search_exa, which IS a TOOLS key).
# 2. Otherwise prefer the clean double-stripped form when both
# prefix and suffix are present
# (exa_web_fetch_exa → web_fetch, for humanization).
# 3. Fall back to single-stripped when double-stripped is empty.
def strip_mcp_decoration(name, prefix)
return nil unless name.start_with?(prefix)
stripped = name.delete_prefix(prefix)
return stripped if KNOWN.include?(stripped)
suffix = "_#{prefix.chomp('_')}"
return stripped unless stripped.end_with?(suffix)
double = stripped.delete_suffix(suffix)
double.empty? ? stripped : double
end
end
end

80
lib/opencode/transform.rb Normal file
View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
module Opencode
# A per-product rule that converts an Opencode::SandboxFile into an
# Opencode::Artifact, owning the trust boundary between "bytes the
# 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. 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):
#
# source_filename — basename in the sandbox the transform
# reads from
# destination_filename — filename of the Artifact the transform
# returns from #render
# render(sandbox_file) — return an Artifact carrying the rendered
# bytes + trust metadata. Raise
# Opencode::Transform::Error to abort just
# this file (substrate logs + skips).
# trusted?(attachment) — true if the attachment was produced by
# this transform (used by Impostor.for and
# by view code that decides inline-render
# vs download). Default: filename match.
# purge_impostors? — if true, before attaching the substrate
# deletes any existing attachment whose
# filename matches destination_filename
# but fails trusted?. Default: false.
#
# `applies_to?(sandbox_file)` is the routing predicate the substrate
# uses to decide whether to send this file through this transform.
# Default is exact match against source_filename; override for
# multi-file or glob-style ownership.
class Transform
Error = Class.new(StandardError)
def destination_filename
raise NotImplementedError, "#{self.class.name} must implement #destination_filename"
end
def source_filename
raise NotImplementedError, "#{self.class.name} must implement #source_filename"
end
def applies_to?(sandbox_file)
sandbox_file.basename == source_filename
end
def render(_sandbox_file)
raise NotImplementedError, "#{self.class.name} must implement #render"
end
def trusted?(attachment)
attachment.filename.to_s == destination_filename
end
def purge_impostors?
false
end
# Names this transform owns end-to-end. The substrate uses this to
# keep its tool-extracted phase from racing the transform — the
# agent's raw payload (source_filename) and the rendered output
# (destination_filename) are both off-limits to the default attach
# path so the transform owns the slot.
def owned_filenames
[ source_filename, destination_filename ]
end
end
end

642
lib/opencode/turn.rb Normal file
View File

@@ -0,0 +1,642 @@
# frozen_string_literal: true
module Opencode
# Opencode::Turn is an INTERNAL procedure object.
#
# Its constructor signature (14 keyword arguments) is NOT part of the
# gem's public API. Use the higher-level affordances on
# Opencode::Client instead:
#
# Opencode::Client#stream(session_id, prompt) { |part| ... }
# → block-form streaming for live partials, returns Reply::Result
#
# Opencode::Client#send_message(session_id, prompt)
# → sync send-and-poll for the simple no-streaming case
#
# If you find yourself instantiating Turn directly, file an issue —
# that's a signal we need a higher-level API you can't yet reach.
#
# Subject to change without major-version bump. See lib/opencode/CLAUDE.md
# 'Conventions and known debt' section.
# One streaming turn against an Opencode session.
#
# A "turn" is one user-message + one assistant-response cycle. Turn drives
# that cycle to completion: ensure the session, send the query, stream
# events into a Reply, recover from common failures, persist the final
# assistant message, and produce an Opencode::Turn::Result.
#
# Honest about the shape: this is a procedure object that wraps the data
# of one turn (message, subject, exchange, reply) with the strategies
# that drive it (session lifecycle, observer, system context, agent name)
# and the sinks that consume the result (tracer, callbacks). The design
# alternatives — abstract base class with hook methods, or factoring out
# an explicit Pipeline state machine — were considered. The first is the
# POODR-flagged inheritance-for-code-reuse anti-pattern. The second adds
# a layer without changing the size of the procedure. We picked the
# smallest honest shape.
#
# Composition over inheritance: every product-specific concern is a
# collaborator passed in. Turn never sees any specific product by
# name — the orchestration shape is uniform.
#
# Collaborators
# -------------
#
# session_for Opencode::Session-shaped. Responds to:
# - #ensure!(client) -> session_id String
# - #recreate!(client) -> session_id String
# - #just_created? -> Boolean (true iff the most recent
# ensure!/recreate! actually minted a fresh session).
#
# observer_factory callable: ->(message) returning an observer that
# responds to #watch(reply). Concretely:
# ->(message) { MyApp::ReplyStream.new(message: message) }.
#
# system_context callable: ->(subject) -> String system prompt.
#
# agent_name callable: ->(subject) -> String agent slug.
#
# tracer Opencode::Tracer-shaped. Responds to
# #call(name, **payload). Receives unprefixed event
# names; the tracer prepends the product namespace.
#
# on_finalized callable: ->(message, exchange) called after the
# assistant message is persisted in :completed.
# Errors raised here are reported and contained;
# they do not flip the message back to :error.
#
# on_turn_finished callable: ->(result) where result is an
# Opencode::Turn::Result. Called once at the end of
# every turn (any path). Errors raised here are
# reported and contained.
#
# on_activity_tick callable: ->(subject) called periodically during
# streaming so callers can keep the user's container
# warm. Default: no-op.
#
# Required record-shape contract on `subject`:
#
# subject.id
# subject.opencode_session_id
#
# Required record-shape contract on `message` (assistant message):
#
# message.id
# message.reload
# message.cancelled?
# message.finalize!(**attrs) # CAS update from :pending state
# message.update!(content:, status:) # for cancellation + error fallback
#
# Public API: only `#call`. Never raises in normal operation; all errors
# are translated into a marked-error message and an on_turn_finished
# callback with `result.failed?`.
class Turn
DEFAULT_EMPTY_STREAM_RETRY_DELAY = 2.seconds
DEFAULT_FINAL_EXCHANGE_TIMEOUT = 120.seconds
DEFAULT_FINAL_EXCHANGE_RETRY_DELAY = 2.seconds
ACTIVITY_TOUCH_INTERVAL = 5.minutes.to_i
ERROR_FALLBACK_CONTENT = "Sorry, an error occurred while generating this response."
# The result of running one Turn. A value object so the Symbol-vs-String
# status confusion that lived inside the old `emit_turn_finished` payload
# has one source of truth: the Result. Callbacks ask `result.completed?`;
# trace consumers ask `result.trace_payload`.
class Result
attr_reader :status, :message, :duration_ms, :cost,
:input_tokens, :output_tokens, :error
# status: :completed | :cancelled | :error | :failed
def initialize(status:, message:, duration_ms:, error: nil)
@status = status
@message = message
@duration_ms = duration_ms
@error = error
if message.respond_to?(:cost)
@cost = message.cost
@input_tokens = message.input_tokens
@output_tokens = message.output_tokens
end
end
def completed? = @status == :completed
def cancelled? = @status == :cancelled
def errored? = @status == :error
def failed? = @status == :failed
# The trace-event-shaped payload. Status as String to keep dashboard
# query compatibility with pre-refactor traces. tool_count optional.
def trace_payload(tool_count: nil)
payload = {
status: @status.to_s,
duration_ms: @duration_ms,
cost: @cost,
input_tokens: @input_tokens,
output_tokens: @output_tokens
}
if @error
payload[:error] = @error.class.name
payload[:error_message] = @error.message.to_s.truncate(200)
end
payload[:tool_count] = tool_count if tool_count
payload.compact
end
end
def initialize(
message:,
subject:,
query_text:,
client:,
session_for:,
observer_factory:,
system_context:,
agent_name:,
tracer:,
on_finalized: ->(_msg, _ex) { },
on_turn_finished: ->(_result) { },
on_activity_tick: ->(_subject) { },
empty_stream_retry_delay: DEFAULT_EMPTY_STREAM_RETRY_DELAY,
final_exchange_timeout: DEFAULT_FINAL_EXCHANGE_TIMEOUT,
final_exchange_retry_delay: DEFAULT_FINAL_EXCHANGE_RETRY_DELAY,
error_fallback_content: ERROR_FALLBACK_CONTENT,
error_feature: "opencode.turn"
)
@message = message
@subject = subject
@query_text = query_text
@client = client
@session_for = session_for
@observer_factory = observer_factory
@system_context = system_context
@agent_name = agent_name
@tracer = tracer
@on_finalized = on_finalized
@on_turn_finished = on_turn_finished
@on_activity_tick = on_activity_tick
@empty_stream_retry_delay = empty_stream_retry_delay
@final_exchange_timeout = final_exchange_timeout
@final_exchange_retry_delay = final_exchange_retry_delay
@error_fallback_content = error_fallback_content
@error_feature = error_feature
@pre_turn_message_count = 0
end
def call
@turn_started_at = monotonic_now
emit("response.started", subject_id: @subject.id, message_id: @message.id)
attempted_recreate = false
begin
run_turn
rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError
raise if attempted_recreate
@session_for.recreate!(@client)
# Distinguish the recovery-with-resend path: if our original
# async send was already accepted upstream, the recreate means
# the upstream may now have orphan work it's still spending on.
# The on-call engineer needs this distinction at 3am.
emit("session.recreated_with_resend",
session_id: @subject.opencode_session_id, subject_id: @subject.id)
attempted_recreate = true
retry
end
rescue StandardError => e
handle_unexpected_error(e)
end
private
# ---- Pipeline -------------------------------------------------------
def run_turn
session_id = @session_for.ensure!(@client)
emit_session_created_if_new
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)
exchange = fetch_current_exchange(session_id)
stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange)
last_assistant = exchange.reverse_each.detect { |m| m.dig(:info, :role) == "assistant" }
@message.reload
if @message.cancelled?
save_cancelled_response(stream_result, last_assistant)
elsif stream_result[:full_text].blank?
recover_empty_stream(session_id, last_assistant, exchange)
else
finalize_response(stream_result, last_assistant, exchange)
end
end
def validate_session!(session_id)
messages = @client.get_messages(session_id)
@pre_turn_message_count = messages.is_a?(Array) ? messages.size : 0
end
# `session.created` is emitted iff the session was *just* created by
# the call to `ensure!` above. We ask the Session — which did the
# work and knows the answer — instead of the subject's AR dirty
# tracking, which would couple Turn to ActiveRecord-shaped records.
def emit_session_created_if_new
return unless @session_for.respond_to?(:just_created?)
return unless @session_for.just_created?
emit("session.created", session_id: @subject.opencode_session_id, subject_id: @subject.id)
end
# ---- Streaming ------------------------------------------------------
def stream_response(session_id)
reply = Opencode::Reply.new
@reply = reply
@observer_factory.call(@message).watch(reply)
stream_started_at = monotonic_now
last_activity_touch_at = stream_started_at
first_token_at = nil
event_count = 0
begin
release_active_record_connections
# Throttled activity tick — fires on EVERY event including heartbeats
# (via the stream_events :on_activity_tick kwarg). We need heartbeats
# to count so that a user taking 30+ minutes to answer an ask-user
# prompt keeps the container warm: the agent itself emits no events
# while suspended, only the server's keep-alive does.
#
# The 5-minute throttle bounds DB write rate (one
# update_column per tick, not per heartbeat). Reaper safety
# is independent: the reaper's 30-minute idle threshold gives
# 6× headroom over this throttle, so even if several ticks
# miss the container survives.
#
# Wrapped in rescue so a transient DB blip on touch_activity
# is observable but doesn't kill an otherwise-healthy in-flight
# stream (heartbeats are advisory; next tick retries).
activity_tick = ->(_event) {
if (monotonic_now - last_activity_touch_at) >= ACTIVITY_TOUCH_INTERVAL
begin
@on_activity_tick.call(@subject)
last_activity_touch_at = monotonic_now
rescue StandardError => e
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
context: { feature: @error_feature, hook: :on_activity_tick })
end
end
}
@client.stream_events(
session_id: session_id,
reply: reply,
on_activity_tick: activity_tick
) do |event|
event_count += 1
reply.apply(event)
first_token_at ||= monotonic_now if reply.first_text_seen?
end
emit("stream.completed",
duration_ms: elapsed_ms(stream_started_at),
first_token_ms: first_token_at && ((first_token_at - stream_started_at) * 1000).round,
event_count: event_count,
tool_count: reply.respond_to?(:tool_count) ? reply.tool_count : nil)
rescue Opencode::SessionNotFoundError
raise
rescue StandardError => e
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
context: { feature: @error_feature, error_class: e.class.name })
emit("stream.interrupted",
duration_ms: elapsed_ms(stream_started_at),
event_count: event_count,
error: e.class.name,
error_message: e.message.to_s.truncate(200))
attempt_stream_recovery(session_id, reply)
end
reply.result
end
def release_active_record_connections
return unless defined?(ActiveRecord::Base)
ActiveRecord::Base.connection_handler.clear_active_connections!
end
# If the session API is still reachable, fetch the current exchange
# and rebaseline `reply` to whatever the server has. If the API is
# also unreachable, keep whatever the reply accumulated before the
# interruption.
def attempt_stream_recovery(session_id, reply)
exchange = fetch_current_exchange(session_id)
last_msg = exchange.reverse_each.detect { |m| m.dig(:info, :role) == "assistant" }
return unless last_msg
recovered_parts = Opencode::ResponseParser.extract_interleaved_parts(last_msg)
reply.replace_parts(recovered_parts) if recovered_parts.any?
rescue StandardError
# Session API also unreachable; keep whatever the reply has.
end
# ---- Finalize -------------------------------------------------------
def finalize_response(stream_result, last_assistant, exchange)
result = authoritative_result(stream_result, exchange)
attrs = {
content: result[:full_text],
tool_calls_json: result[:tool_parts],
parts_json: result[:parts_json],
status: :completed
}
attrs[:reasoning] = result[:reasoning_text] if result[:reasoning_text].present?
attrs.merge!(extract_cost(last_assistant)) if last_assistant
unless @message.finalize!(**attrs)
# finalize! returns false if message was cancelled/errored mid-flight.
emit_turn_finished(status: :cancelled)
return
end
# Callbacks run AFTER the system of record is durable. If a callback
# raises (Redis flake on Turbo broadcast, ActiveJob enqueue hiccup
# on title generation), the turn is still completed; the failure
# is reported and isolated. Without this isolation a successful
# turn could be flipped to :error by an unrelated infra hiccup.
safe_callback(:on_finalized) { @on_finalized.call(@message, exchange) }
emit_turn_finished(status: :completed)
end
def authoritative_result(stream_result, exchange)
exchange_result = current_turn_result(exchange)
return stream_result unless exchange_result
return stream_result if exchange_result[:full_text].blank?
merge_stream_only_parts(stream_result, exchange_result)
end
# The final session poll is authoritative for answer text and terminal
# tool payloads, but OpenCode emits some events (`todo.updated`, and
# whatever future bus events join Opencode::PartSource::STREAM_ONLY)
# that never persist as message parts. Preserve those synthetic
# stream parts across finalization so the refresh-rendered UI does
# not drop the live state the user watched stream in.
def merge_stream_only_parts(stream_result, exchange_result)
stream_parts = Array(stream_result[:parts_json])
return exchange_result unless stream_parts.any? { |part| Opencode::PartSource.stream_only?(part) }
exchange_parts = Array(exchange_result[:parts_json]).dup
merged = []
stream_parts.each do |part|
if Opencode::PartSource.stream_only?(part)
merged << part
elsif exchange_parts.any?
merged << exchange_parts.shift
end
end
merged.concat(exchange_parts)
Opencode::Reply.distill(merged)
end
def wait_for_final_exchange_result(session_id, stream_result, exchange)
result = authoritative_result(stream_result, exchange)
sync_reply_from_result(result)
return [ result, exchange ] if terminal_exchange_result?(result, exchange)
return [ result, exchange ] unless exchange_indicates_more_work?(exchange)
emit("response.waiting_for_final_text", subject_id: @subject.id, message_id: @message.id)
deadline = monotonic_now + @final_exchange_timeout
loop do
return [ result, exchange ] if monotonic_now >= deadline
sleep @final_exchange_retry_delay if @final_exchange_retry_delay.positive?
exchange = fetch_current_exchange(session_id)
result = authoritative_result(stream_result, exchange)
sync_reply_from_result(result)
return [ result, exchange ] if terminal_exchange_result?(result, exchange)
return [ result, exchange ] unless exchange_indicates_more_work?(exchange)
end
end
def sync_reply_from_result(result)
return unless @reply.respond_to?(:sync_recovered_parts)
return if result[:parts_json].blank?
@reply.sync_recovered_parts(result[:parts_json])
end
def terminal_exchange_result?(result, exchange)
return false if result[:full_text].blank?
last_assistant = current_turn_assistant_messages(exchange).last
return true unless last_assistant
return false if assistant_in_progress?(last_assistant)
assistant_finish(last_assistant) != "tool-calls"
end
def exchange_indicates_more_work?(exchange)
last_assistant = current_turn_assistant_messages(exchange).last
return false unless last_assistant
assistant_finish(last_assistant) == "tool-calls" || assistant_in_progress?(last_assistant)
end
def assistant_finish(assistant_message)
assistant_message.dig(:info, :finish).to_s
end
def assistant_in_progress?(assistant_message)
time = assistant_message.dig(:info, :time)
return false unless time.is_a?(Hash)
return false unless time.key?(:created)
time[:completed].blank?
end
def current_turn_result(exchange)
parts = current_turn_assistant_messages(exchange).flat_map do |assistant_message|
Opencode::ResponseParser.extract_interleaved_parts(assistant_message)
end
return nil if parts.empty?
Opencode::Reply.distill(parts)
end
def current_turn_assistant_messages(exchange)
Array(exchange).select { |message| message.dig(:info, :role) == "assistant" }
end
def save_cancelled_response(stream_result, last_assistant)
content = stream_result[:full_text].presence || "Response was stopped."
attrs = {
content: content,
tool_calls_json: stream_result[:tool_parts],
parts_json: stream_result[:parts_json]
}
attrs[:reasoning] = stream_result[:reasoning_text] if stream_result[:reasoning_text].present?
attrs.merge!(extract_cost(last_assistant)) if last_assistant
@message.update!(**attrs)
emit_turn_finished(status: :cancelled)
end
# ---- Empty-stream recovery ------------------------------------------
def recover_empty_stream(session_id, last_assistant, exchange)
recovered = recover_from_exchange(last_assistant)
unless recovered
sleep @empty_stream_retry_delay if @empty_stream_retry_delay.positive?
exchange = fetch_current_exchange(session_id)
last_assistant = exchange.reverse_each.detect { |m| m.dig(:info, :role) == "assistant" }
recovered = recover_from_exchange(last_assistant)
end
if recovered
emit("response.recovered_from_exchange")
finalize_response(recovered, last_assistant, exchange)
elsif detect_upstream_error(last_assistant)
mark_error(reason: "upstream_llm_error")
else
mark_error(reason: "empty_stream")
end
end
def recover_from_exchange(assistant_message)
return nil unless assistant_message
parts_json = Opencode::ResponseParser.extract_interleaved_parts(assistant_message)
return nil if parts_json.empty?
result = Opencode::Reply.distill(parts_json)
return nil if result[:full_text].blank?
result
end
def detect_upstream_error(assistant_message)
return nil unless assistant_message
error = Opencode::ResponseParser.extract_error(assistant_message)
return nil unless error
emit("response.upstream_error",
error_name: error[:name],
error_message: error[:message],
status_code: error[:status_code],
provider_url: error[:url])
Opencode::ErrorReporter.report(
Opencode::Error.new("Upstream LLM error: #{error[:name]} - #{error[:message]}"),
handled: true,
severity: :error,
context: { feature: @error_feature, **error }
)
error
end
# ---- Error paths ----------------------------------------------------
# Both error paths transition the message to :error through the
# CAS-safe Message#error! contract — a concurrent cancel that already
# moved the row out of :pending wins, and the canceller's terminal
# state survives. emit_turn_finished re-reads the persisted state
# (Result.message is reloaded) so callbacks receive the actual
# current state, not the state we wished we wrote.
def handle_unexpected_error(e)
Opencode::ErrorReporter.report(e, handled: true, severity: :error,
context: { feature: @error_feature, message_id: @message.id, error_class: e.class.name })
@message.error!(@error_fallback_content)
emit_turn_finished(status: :failed, error: e)
end
def mark_error(reason:)
emit("response.error", reason: reason, message_id: @message.id, subject_id: @subject.id)
@message.error!(@error_fallback_content)
emit_turn_finished(status: :error)
end
# ---- Trace + callback helpers --------------------------------------
def emit(name, **payload)
@tracer.call(name, **payload)
end
def emit_turn_finished(status:, error: nil)
@message.reload if @message.respond_to?(:reload)
result = Result.new(
status: status,
message: @message,
duration_ms: elapsed_ms(@turn_started_at),
error: error
)
safe_callback(:on_turn_finished) { @on_turn_finished.call(result) }
tool_count = @message.respond_to?(:tool_calls_json) ? @message.tool_calls_json&.size.to_i : nil
emit("turn.finished", **result.trace_payload(tool_count: tool_count))
end
# Run a callback, report any exception, but keep the turn in its
# current durable state. Side-effect callbacks (broadcast, artifact
# collection, title enqueueing) are not allowed to overwrite
# :completed → :error after the message is already persisted.
def safe_callback(name)
yield
rescue StandardError => e
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
context: { feature: @error_feature, callback: name, message_id: @message.id, error_class: e.class.name })
emit("callback.error", callback: name.to_s, error_class: e.class.name)
end
# ---- Exchange + cost helpers ---------------------------------------
def fetch_current_exchange(session_id)
messages = @client.get_messages(session_id)
return [] unless messages.is_a?(Array) && messages.any?
search_start_idx = [ @pre_turn_message_count.to_i, messages.length ].min
last_user_idx = nil
(messages.length - 1).downto(search_start_idx) do |idx|
message = messages[idx]
if message.dig(:info, :role) == "user" && user_message_text(message) == @query_text.to_s
last_user_idx = idx
break
end
end
return [] unless last_user_idx
messages[(last_user_idx + 1)..]
rescue Opencode::Error => e
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
context: { feature: @error_feature, action: "fetch_current_exchange", session_id: session_id })
[]
end
def user_message_text(message)
Opencode::ResponseParser.extract_text(message).to_s
end
def extract_cost(assistant_msg)
cost = Opencode::ResponseParser.extract_cost(assistant_msg)
cache = Opencode::ResponseParser.extract_cache_tokens(assistant_msg)
tokens = Opencode::ResponseParser.extract_tokens(assistant_msg) || {}
{
cost: cost,
input_tokens: tokens[:input],
output_tokens: tokens[:output],
cache_read_tokens: cache[:cache_read],
cache_write_tokens: cache[:cache_write]
}.compact
end
# ---- Time helpers ---------------------------------------------------
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
def elapsed_ms(t) = ((monotonic_now - t) * 1000).round
end
end

View File

@@ -0,0 +1,85 @@
# frozen_string_literal: true
module Opencode
# The prompt body to send to an OpenCode agent when the user attached
# files: the user's text plus an instruction block naming each file by
# its sandboxed filename so the agent can read it with the `read` tool.
#
# Two outputs, both explicit:
#
# text — the prompt body to pass to send_message_async
# sandbox_file_names — map of sandbox_name => original filename,
# used by ReplyStream to show the user a
# recognizable name when the agent reads the
# file back.
#
# Previously this work lived in `Opencode::SandboxFiles`, an ActiveSupport
# concern that mutated a hidden `@sandbox_file_names` instance variable on
# the including job. ReplyStream then read that ivar back through a
# closure. State across class boundaries via shared mutable ivars is the
# kind of Sandi-smelly action-at-a-distance that breaks the moment
# someone forgets the contract. This value object replaces that with two
# named return values.
#
# Side effect, unchanged from the concern: file bytes are copied from
# ActiveStorage into the per-user OpenCode sandbox directory so the
# agent can read them with the `read` tool. The copy is path-escape
# guarded (the cleanpath of the destination must start with the
# sandbox dir prefix, no symlink trickery).
class UploadedFilesPrompt
attr_reader :text, :sandbox_file_names
def initialize(user_message:, sandbox_path:, sandbox_name_for:)
@user_message = user_message
@sandbox_path = sandbox_path
@sandbox_name_for = sandbox_name_for
@sandbox_file_names = {}
@text = build_text
end
private
def build_text
raw = @user_message.content.to_s
return raw unless @user_message.files.attached?
file_instructions = @user_message.files.map do |file|
sandbox_file = copy_to_sandbox(file)
@sandbox_file_names[sandbox_file.sandbox_name] = file.filename.to_s
"#{file.filename} -> #{sandbox_file.sandbox_name} (#{file.content_type}, #{file.byte_size} bytes)"
end
[
raw,
"",
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly, then consult your reference materials and verify any legal claims before responding:",
*file_instructions
].join("\n").strip
end
def copy_to_sandbox(file)
FileUtils.mkdir_p(@sandbox_path)
sandbox_name = @sandbox_name_for.call(file)
dest = File.join(@sandbox_path, sandbox_name)
resolved = Pathname.new(dest).cleanpath.to_s
unless resolved.start_with?(@sandbox_path)
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
end
File.open(dest, "wb") { |f| f.write(file.download) }
Placement.new(sandbox_name, dest)
end
# Tiny value pair returned by copy_to_sandbox: the canonical filename
# the agent should read by, and the on-disk path the file ended up at.
# Internal to UploadedFilesPrompt — the caller (UploadedFilesPrompt
# itself) only needs the sandbox_name to embed in the prompt text.
Placement = Struct.new(:sandbox_name, :path) do
def to_s
path
end
end
end
end

51
opencode-rails.gemspec Normal file
View File

@@ -0,0 +1,51 @@
# frozen_string_literal: true
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 = ["opencode-rails@ajay.to"]
spec.summary = "Production-grade Rails integration for OpenCode."
spec.description = <<~DESC
Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle
(idempotent ensure!/recreate!/abort! with row-level locks), a Turn
orchestrator that drives the Reply state machine + handles
session-not-found recovery, 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 your own
boilerplate.
DESC
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}/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. 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
# ActionMailer, ActionCable, ActionView, etc. just to use this gem.
spec.add_runtime_dependency "activerecord", ">= 7.1", "< 9.0"
spec.add_runtime_dependency "activestorage", ">= 7.1", "< 9.0"
spec.add_runtime_dependency "activesupport", ">= 7.1", "< 9.0"
spec.add_development_dependency "minitest", "~> 5.20"
spec.add_development_dependency "rake", "~> 13.0"
end

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
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 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(
filename: "notes.md",
content: "# Notes",
content_type: "text/markdown"
)
assert_equal "notes.md", artifact.filename
assert_equal "# Notes", artifact.content
assert_equal "text/markdown", artifact.content_type
end
def test_metadata_defaults_to_empty_hash
artifact = Opencode::Artifact.new(filename: "x.txt", content: "hi", content_type: "text/plain")
assert_equal({}, artifact.metadata)
end
def test_metadata_accepts_trust_hash
artifact = Opencode::Artifact.new(
filename: "x.html",
content: "<p>",
content_type: "text/html",
metadata: { trust: "host_rendered" }
)
assert_equal "host_rendered", artifact.metadata[:trust]
end
end

View File

@@ -0,0 +1,75 @@
# frozen_string_literal: true
require "test_helper"
class Opencode::ErrorReporterTest < Minitest::Test
def setup
@original_adapter = Opencode::ErrorReporter.adapter
Opencode::ErrorReporter.adapter = nil
end
def teardown
Opencode::ErrorReporter.adapter = @original_adapter
end
def test_report_is_no_op_without_adapter
# Must not raise, must return nil.
result = Opencode::ErrorReporter.report(StandardError.new("boom"))
assert_nil result
end
def test_report_forwards_to_adapter
captured = []
Opencode::ErrorReporter.adapter = ->(error, **opts) {
captured << [error, opts]
:sentinel
}
err = ArgumentError.new("bad arg")
result = Opencode::ErrorReporter.report(err, severity: :error, context: { foo: 1 })
assert_equal :sentinel, result
assert_equal 1, captured.length
captured_error, captured_opts = captured.first
assert_same err, captured_error
assert_equal :error, captured_opts[:severity]
assert_equal({ foo: 1 }, captured_opts[:context])
end
def test_report_accepts_no_keyword_args
invoked = false
Opencode::ErrorReporter.adapter = ->(error, **opts) {
invoked = true
assert_empty opts
refute_nil error
}
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"
# Smoke test for Opencode::Exchange — verifies it instantiates, can be
# given empty input, and exposes the public surface (`tool_artifacts`).
# Deeper behavioral tests live in the host application
# (test/lib/opencode/rails/exchange_test.rb) where AR fixtures, real
# wire shapes, and the apply-patch event stream are available.
class Opencode::ExchangeTest < Minitest::Test
def test_initializes_with_empty_messages
exchange = Opencode::Exchange.new([])
assert_equal [], exchange.tool_artifacts
end
def test_initializes_with_nil_messages_via_array_coerce
exchange = Opencode::Exchange.new(nil)
assert_equal [], exchange.tool_artifacts
end
def test_tool_artifacts_supports_exclude_kwarg
exchange = Opencode::Exchange.new([])
# Should not raise when given an exclude list against empty input.
assert_equal [], exchange.tool_artifacts(exclude: %w[notes.md])
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

@@ -0,0 +1,72 @@
# frozen_string_literal: true
require "test_helper"
# Smoke test: every constant the gem promises is defined and points at
# the right kind of object. If require "opencode-rails" loads cleanly,
# this passes. If the require chain drifts or a file fails to load,
# this catches it before downstream apps do.
class Opencode::LoadingTest < Minitest::Test
GEM_PROVIDED_CONSTANTS = %w[
Session Turn Exchange Artifact Sandbox SandboxFile
Transform Impostor MessageArtifacts UploadedFilesPrompt
ToolDisplay ErrorReporter
].freeze
# Reply / Tracer / Client / etc. ship in opencode-ruby and are
# transitively required by opencode-rails' umbrella. Verify the
# require chain pulled them in.
TRANSITIVE_CONSTANTS_FROM_OPENCODE_RUBY = %w[
Client Reply ReplyObserver Tracer Prompts
ResponseParser ToolPart PartSource Todo
Instrumentation Error
].freeze
def test_gem_provides_expected_constants
GEM_PROVIDED_CONSTANTS.each do |name|
assert Opencode.const_defined?(name),
"Expected Opencode::#{name} to be defined after `require \"opencode-rails\"`"
end
end
def test_transitively_loads_opencode_ruby_constants
TRANSITIVE_CONSTANTS_FROM_OPENCODE_RUBY.each do |name|
assert Opencode.const_defined?(name),
"Expected Opencode::#{name} to be defined transitively via opencode-ruby"
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 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 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
assert_match(/\A\d+\.\d+\.\d+/, Opencode::RAILS_VERSION)
end
def test_no_opencode_rails_module
# Defining Opencode::Rails as a module would shadow ::Rails for any
# host code that references top-level Rails.* from inside the
# Opencode:: namespace (e.g. lib/opencode/containers/container.rb).
# Verify the namespace stays clean — version lives at
# Opencode::RAILS_VERSION, not Opencode::Rails::VERSION.
refute Opencode.const_defined?(:Rails),
"Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace"
end
end

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

6
test/test_helper.rb Normal file
View File

@@ -0,0 +1,6 @@
# frozen_string_literal: true
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "opencode-rails"
require "minitest/autorun"