24 Commits

Author SHA1 Message Date
9937e45f5e Finalize alpha9 release safeguards 2026-07-20 20:51:01 -07:00
427bd47648 Prepare repaired alpha9 release 2026-07-20 20:04:59 -07:00
b02bcc74ef Harden alpha8 publication safety 2026-07-20 14:26:22 -07:00
a9add2a7c1 Prepare opencode-rails alpha8 2026-07-19 22:21:41 -07:00
61beb17f55 Upgrade checkout action to v7 2026-07-19 18:15:10 -07:00
8b78a558e1 Harden workflow action pin discovery 2026-07-19 17:29:10 -07:00
4d975d99b3 Pin CI actions and test Ruby 4 2026-07-19 16:23:20 -07:00
ajay@krishnan.ca
b27d39edf4 Merge pull request 'Run trusted gem releases only on GitHub' (#1) from fix/github-only-release-runner-guard-20260719 into main 2026-07-19 12:37:48 -07:00
75e4435d9d Test GitHub-only gem release boundary 2026-07-19 06:50:51 -07:00
6148fc67da Run gem releases only on GitHub 2026-07-19 04:30:50 -07:00
Ajay Krishnan
deeccb119e Merge pull request #3 from ajaynomics/docs/readme-executable-quickstart
docs: make alpha7 Rails quickstart executable
2026-07-18 15:31:57 -07:00
fe953020d5 docs: make Rails quickstart match Turn API 2026-07-18 15:29:57 -07:00
Ajay Krishnan
2a391ccad1 Merge pull request #2 from ajaynomics/fix/turn-subscribe-before-prompt-alpha7
Submit Rails turns after SSE readiness
2026-07-18 14:39:25 -07:00
7744fe211a Submit Rails turns after SSE readiness 2026-07-18 14:37:24 -07:00
Ajay Krishnan
17025f0ed9 Merge pull request #1 from ajaynomics/release/alpha6
Release opencode-rails 0.0.1.alpha6
2026-07-18 13:43:15 -07:00
451ef97b9f Build packaged dependency from its source root 2026-07-18 13:41:14 -07:00
b0e8cf8e20 Make loading provenance assertion path-specific 2026-07-18 13:40:03 -07:00
de51ff3a45 Make lockstep dependency CI-resolvable 2026-07-18 13:38:22 -07:00
ca08bb36e8 Add trusted gem publishing 2026-07-18 13:32:36 -07:00
a5bd656144 Release opencode-rails alpha6 2026-07-18 13:31:13 -07:00
ff6187118d Release opencode-rails alpha5
Some checks failed
Test / test (3.2) (push) Failing after 10s
Test / test (3.3) (push) Failing after 11s
Test / test (3.4) (push) Failing after 11s
2026-07-15 20:20:17 -07:00
3af78b9716 Release opencode-rails alpha4
Some checks failed
Test / test (3.2) (push) Failing after 1s
Test / test (3.3) (push) Failing after 1s
Test / test (3.4) (push) Failing after 1s
2026-07-12 01:55:56 -07:00
83b0a4ee77 Release alpha3 client dependency
Some checks failed
Test / test (3.3) (push) Failing after 1s
Test / test (3.4) (push) Failing after 1s
Test / test (3.2) (push) Failing after 36s
2026-07-10 19:29:31 -07:00
9b0c4cd3cd Initial public release v0.0.1.alpha2
Some checks failed
Test / test (3.2) (push) Failing after 9m43s
Test / test (3.3) (push) Failing after 10m0s
Test / test (3.4) (push) Failing after 10m0s
opencode-rails — production-grade Rails integration for OpenCode.

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

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

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

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

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

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

@@ -0,0 +1,81 @@
name: Push gem
on:
push:
tags:
- "v*"
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Verify tag matches gem version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: >-
ruby -Ilib -ropencode/rails_version -e
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
abort "tag #{expected.inspect} does not match gem #{Opencode::RAILS_VERSION.inspect}"
unless Opencode::RAILS_VERSION == expected'
- name: Run tests
run: bundle exec rake test
- name: Build gem
run: gem build opencode-rails.gemspec
- name: Verify published client and installed gems
run: |
client_version="$(bundle exec ruby -ropencode/version -e 'print Opencode::VERSION')"
rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version -e 'print Opencode::RAILS_VERSION').gem"
registry_dir="${RUNNER_TEMP}/opencode-ruby-registry-${{ matrix.ruby }}"
mkdir -p "$registry_dir"
(cd "$registry_dir" && gem fetch opencode-ruby --version "$client_version" --prerelease --clear-sources --source https://rubygems.org)
client_gem="$registry_dir/opencode-ruby-${client_version}.gem"
export GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"
export GEM_PATH="${GEM_HOME}"
mkdir -p "$GEM_HOME"
gem install "$client_gem" --no-document --clear-sources --source https://rubygems.org
gem install "$rails_gem" --no-document --clear-sources --source https://rubygems.org
ruby -ropencode-rails -e '
root = File.realpath(ENV.fetch("GEM_HOME"))
%w[opencode-ruby opencode-rails].each do |name|
path = File.realpath(Gem.loaded_specs.fetch(name).full_gem_path)
abort "#{name} loaded outside isolated GEM_HOME: #{path}" unless path.start_with?("#{root}/")
end
puts Opencode::RAILS_VERSION
'
push:
needs: verify
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
environment: release
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: "4.0"
bundler-cache: true
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0

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

@@ -0,0 +1,37 @@
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", "4.0"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
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
run: |
client_dir="$(bundle show opencode-ruby)"
(cd "$client_dir" && gem build opencode-ruby.gemspec)
gem install "$client_dir"/opencode-ruby-*.gem --no-document
gem install opencode-rails-*.gem --no-document
ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION'

8
.gitignore vendored Normal file
View File

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

146
CHANGELOG.md Normal file
View File

@@ -0,0 +1,146 @@
# Changelog
## 0.0.1.alpha9 - 2026-07-20
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha9`, carrying the
hardened SSE framing parser while retaining the alpha7 subscribe-before-
prompt and at-most-once reconnect contract.
### Fixed
- Keep transform destination filenames out of the agent-authored identity
attachment path and require transforms to verify trust explicitly.
- Anchor uploads to an opened sandbox directory across path swaps, replace
destination symlinks without following them, and validate bounded reads from
the opened sandbox file. Upload copying now requires a traversable
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it.
- Reject hard-linked sandbox artifacts and open files with nonblocking,
no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail
closed without hanging artifact collection.
- Make the reference integration deny every OpenCode tool by default and
require project-local OpenCode configuration to be disabled at server
startup. Hosts must add their own OS or container boundary before allowing
filesystem tools.
- Load every runtime standard library explicitly and align the shipped
permissions, observer, Turn, prompt, and instrumentation examples with the
actual APIs.
### Changed
- Gate release on tests plus exact local package installation across Ruby 3.2,
3.3, 3.4, and 4.0 in a read-only verification job.
- Pin every third-party CI and release action to an exact reviewed commit and
use Ruby 4.0 for release builds.
- Fail the trusted-publishing job before release when the pushed tag does not
match `Opencode::RAILS_VERSION`.
- Refuse to publish Rails until the RubyGems client package exists and loads
with the locally built Rails package in an isolated gem repository.
## 0.0.1.alpha8 - 2026-07-20
### Unpublished
- Superseded after the unrepaired `opencode-ruby` alpha8 package was yanked.
No `opencode-rails` alpha8 package was published; use the alpha9 lockstep
tuple.
## 0.0.1.alpha7 - 2026-07-18
### Fixed
- Make `Opencode::Turn` submit `prompt_async` through the transport's
at-most-once `on_subscribed` callback, after `server.connected` proves the
SSE listener is ready. Reconnects reopen only SSE and never replay the user
prompt.
- Fail the turn directly when subscription setup or the prompt POST fails
before a turn is confirmed started. The recovery path no longer risks
finalizing stale exchange text after a pre-turn failure.
- Add a gem-level behavioral regression for the cross-gem ordering contract,
including reconnect and ambiguous prompt timeout cases.
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha7`.
## 0.0.1.alpha6 - 2026-07-18
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha6`. This exposed
subscribe-before-prompt through `Client#stream`, but the lower-level
`Opencode::Turn` path still sent before `stream_events`; that orchestration
gap is fixed in alpha7.
## 0.0.1.alpha5 - 2026-07-15
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha5`, exposing
native parent-linked and configured session creation to Rails hosts.
## 0.0.1.alpha4 - 2026-07-12
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha4`, adding
current `session.status` idle handling and correct multi-assistant tool-loop
finalization for Rails turns.
## 0.0.1.alpha3 - 2026-07-10
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha3`, exposing
the session permission PATCH primitive to Rails host applications while
leaving reconciliation policy in each host.
## 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>.

7
Gemfile Normal file
View File

@@ -0,0 +1,7 @@
source "https://rubygems.org"
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gemspec

215
README.md
View File

@@ -1,3 +1,216 @@
# opencode-rails # 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
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
```ruby
# Gemfile
gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
gem "opencode-rails", "= 0.0.1.alpha9"
```
Until publication is verified, validate this candidate checkout against the
exact `opencode-ruby` source it was tested with:
```ruby
# Gemfile
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gem "opencode-rails",
path: "../opencode-rails"
```
```bash
bundle install
```
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
`marcel`. Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
`UploadedFilesPrompt` anchors upload writes through `/proc/self/fd` or
`/dev/fd` to prevent sandbox-root path swaps. Hosts using upload copying must
provide one of those traversable descriptor filesystems; unsupported platforms
fail closed before writing.
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha9
retains the subscribe-ready-before-prompt transport contract and reconnects an
accepted turn without posting its prompt again, while hardening SSE framing.
The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
`opencode-rails` alpha8 was never published.
`opencode-rails` 0.0.1.alpha9 is a release candidate and is not yet confirmed
published on RubyGems. Because the gem has no RubyGems entry yet, create a
pending trusted publisher for gem `opencode-rails`, repository owner
`ajaynomics`, repository `opencode-rails`, workflow `release.yml`, and
environment `release`. RubyGems converts it to a normal trusted publisher after
the first successful push. The workflow also refuses to publish until the
RubyGems `opencode-ruby` alpha9 package exists and installs and loads with the
locally built Rails package. Until the registry result is verified, pushing a
`v*` tag does not guarantee publication. Trusted publishing does not require a
long-lived RubyGems API key.
## Quickstart
```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/services/noop_reply_observer.rb
#
# Turn requires an observer factory even when the app does not need live
# partial rendering. For a streaming UI, replace this with an observer that
# persists/broadcasts selected ReplyObserver callbacks (and throttle writes).
class NoopReplyObserver
include Opencode::ReplyObserver
def watch(reply)
reply.add_observer(self)
self
end
end
```
```ruby
# app/jobs/generate_response_job.rb
class GenerateResponseJob < ApplicationJob
def perform(assistant_message, user_message)
conversation = assistant_message.conversation
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: working_directory
)
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_for: session,
observer_factory: ->(_message) { NoopReplyObserver.new },
system_context: ->(record) { "You are assisting with #{record.title}." },
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
tracer: ->(name, **payload) {
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
},
on_turn_finished: ->(result) {
# result.status #=> :completed | :cancelled | :error | :failed
# result.message #=> the AR row (reloaded)
# result.duration_ms
}
).call
end
private
def permission_rules_for(_conversation)
[
{ permission: "*", pattern: "*", action: "deny" }
]
end
end
```
Run the OpenCode server with `OPENCODE_DISABLE_PROJECT_CONFIG=1`. OpenCode loads
project configuration, plugins, and instructions while opening a directory,
before session permissions exist; the wildcard deny rule cannot constrain that
bootstrap. Point `OPENCODE_WORKING_DIRECTORY` at a pre-provisioned directory
mounted at the same absolute path in Rails and OpenCode. Treat the server's
global configuration, plugins, and instructions as privileged administrator
code and verify the setting against the deployed OpenCode version.
This quickstart intentionally grants no filesystem tools: OpenCode permissions
alone are not an OS sandbox. Add product-specific allows only when the OpenCode
process also has an independent container or operating-system boundary.
`Opencode::Session` applies permissions only when it creates a session. Before
deploying a directory or permission-policy change, recreate persisted sessions
with a client scoped to the new directory; an existing session ID does not
prove that the new policy was applied.
The host record (here `conversation`) must respond to `#title`,
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
`#error!(content)`. A non-no-op observer may impose additional record methods
for its own live snapshots.
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
constructor is intentionally explicit and may change before 1.0, so keep this
wiring in one host service/job and keep the gem source pinned exactly.
## What you get
| 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 wire client emits `opencode.request` and artifact extraction emits
`opencode.apply_patch.artifacts_dropped` through `Opencode::Instrumentation`.
Turn events flow through the injected tracer; with the Quickstart's
`assistant.` prefix they include:
- `assistant.response.started`, `assistant.turn.finished`
- `assistant.stream.completed`, `assistant.stream.interrupted`
- `assistant.session.created`, `assistant.session.recreated_with_resend`
- `assistant.response.upstream_error`
Swallowed errors flow through `Opencode::ErrorReporter.report(error, **opts)`.
Instrumentation and error reporting are no-ops until the host wires adapters.
## 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,217 @@
# 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.
#
# Contract-tested reference. Copy what you need and adapt it to your domain.
# The OpenCode server process must run with
# OPENCODE_DISABLE_PROJECT_CONFIG=1; session permissions are too late to
# constrain project configuration, plugins, or instructions at bootstrap.
# ----------------------------------------------------------------------
# 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 HTTP requests flow through this adapter as
# `opencode.request` ActiveSupport::Notifications events.
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, _user_message) { "GenerateResponseJob/#{message.conversation_id}" }
def perform(assistant_message, user_message)
return if assistant_message.terminal? # idempotent
conversation = assistant_message.conversation
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: working_directory
)
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
# OpenCode session if conversation.opencode_session_id is blank;
# returns the existing id otherwise.
# Session applies permissions only when it creates a session.
# Recreate persisted sessions before deploying a directory or policy change.
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: ->(record) { build_system_context(record) },
agent_name: ->(_record) { "build" },
tracer: ->(name, **payload) {
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
},
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:`.
[
{ permission: "*", pattern: "*", action: "deny" }
]
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.
<<~CONTEXT
User: #{conversation.user.name}
Conversation: #{conversation.id}
CONTEXT
end
end
# ----------------------------------------------------------------------
# 3. app/services/reply_stream.rb
# ----------------------------------------------------------------------
#
# An Opencode::ReplyObserver implementation that bridges the gem's
# state-machine callbacks (part_added, part_changed, part_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
include Opencode::ReplyObserver
def initialize(message:)
@message = message
@parts_dom_id = "parts_message_#{message.id}"
end
def watch(reply)
reply.add_observer(self)
self
end
def part_added(part:, index:)
Turbo::StreamsChannel.broadcast_append_to(
@message.conversation,
target: @parts_dom_id,
partial: "messages/part",
locals: { part: part, index: index, message: @message }
)
end
def part_changed(part:, index:, delta:)
broadcast_update(part, index)
end
def part_finalized(part:, index:)
broadcast_update(part, index)
end
def tool_progressed(part:, index:, status:, raw:)
broadcast_update(part, index)
end
private
def broadcast_update(part, index)
Turbo::StreamsChannel.broadcast_update_to(
@message.conversation,
target: "part_#{index}_message_#{@message.id}",
partial: "messages/part",
locals: { part: part, index: index, message: @message }
)
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)
# - ReplyObserver callbacks for host-owned live broadcasts or snapshots
# - 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.

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

@@ -0,0 +1,45 @@
# 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 "fileutils"
require "marcel"
require "pathname"
require "set"
require "stringio"
require "tempfile"
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,135 @@
# 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 transform_owned_filenames.include?(file.basename)
# Destination names belong exclusively to their host transform.
# Agent-authored bytes must never fall through as trusted output.
next
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.alpha9"
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,124 @@
# 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
UnsafeFileError = Class.new(Opencode::Error)
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
with_safe_file do |file|
content = file.read(@max_bytes + 1) || "".b
if content.bytesize > @max_bytes
raise UnsafeFileError, "Sandbox file exceeds size limit while reading: #{basename}"
end
content
end
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).
#
# Revalidates the opened file descriptor so a path swap between the
# sandbox scan and the read cannot redirect content outside the sandbox.
def safe?
with_safe_file { true }
rescue UnsafeFileError
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
private
def with_safe_file
before = File.lstat(path)
unless before.file? && !before.symlink? && before.nlink == 1
raise UnsafeFileError, "Unsafe sandbox file: #{basename}"
end
resolved = Pathname.new(path).realpath.to_s
unless resolved.start_with?(sandbox_prefix)
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
end
flags = safe_open_flags
File.open(path, flags, encoding: Encoding::BINARY) do |file|
opened = file.stat
unless opened.file? && opened.nlink == 1 && opened.dev == before.dev && opened.ino == before.ino
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
end
if opened.size > @max_bytes
raise UnsafeFileError, "Sandbox file exceeds size limit: #{basename}"
end
yield file
end
rescue SystemCallError => e
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
end
def safe_open_flags
required = %i[NONBLOCK NOFOLLOW]
missing = required.reject { |name| File.const_defined?(name) }
unless missing.empty?
raise UnsafeFileError, "Platform cannot safely open sandbox files: missing #{missing.join(", ")}"
end
flags = File::RDONLY | File::NONBLOCK | File::NOFOLLOW
flags |= File::BINARY if File.const_defined?(:BINARY)
flags
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

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

@@ -0,0 +1,81 @@
# 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: false; subclasses
# must verify host-authored metadata.
# 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)
false
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

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

@@ -0,0 +1,660 @@
# 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)
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
prompt_attempted = false
prompt_succeeded = false
on_subscribed = lambda do
# stream_events guarantees at-most-once invocation, but keep this
# guard here as a second line of defense because an ambiguous prompt
# response must never become a duplicate model turn.
next false if prompt_attempted
prompt_attempted = true
@client.send_message_async(
session_id, @query_text,
agent: @agent_name.call(@subject),
system: @system_context.call(@subject)
)
prompt_succeeded = true
end
begin
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,
on_subscribed: on_subscribed
) 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
# Subscription rejection or prompt transport failure happened before
# a turn was confirmed started. Recovering from the pre-turn exchange
# could finalize stale text, and retrying an ambiguous POST could
# duplicate spend, so surface the original failure to the outer error
# path without reconnect/recovery.
raise unless prompt_succeeded
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
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,140 @@
# 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: generated names must be basenames, and all writes stay anchored
# to an opened directory handle even if the sandbox path is replaced.
# A temporary file is renamed into place so destination symlinks are
# replaced rather than followed. Retried jobs can refresh existing copies.
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 before responding:",
*file_instructions
].join("\n").strip
end
def copy_to_sandbox(file)
sandbox_path = Pathname.new(@sandbox_path).expand_path
FileUtils.mkdir_p(sandbox_path)
sandbox_stat = File.lstat(sandbox_path)
unless sandbox_stat.directory? && !sandbox_stat.symlink?
raise ArgumentError, "Sandbox root must be a directory, not a symlink: #{sandbox_path}"
end
sandbox_name = @sandbox_name_for.call(file).to_s
unless sandbox_name == File.basename(sandbox_name) && !%w[. ..].include?(sandbox_name)
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
end
File.open(sandbox_path, File::RDONLY) do |directory|
opened_stat = directory.stat
unless opened_stat.directory? && same_file?(sandbox_stat, opened_stat)
raise ArgumentError, "Sandbox root changed while opening: #{sandbox_path}"
end
directory_path = directory_handle_path(directory)
dest = File.join(directory_path, sandbox_name)
mode = destination_mode(dest)
Tempfile.create([ ".opencode-upload-", ".tmp" ], directory_path) do |temp|
temp.binmode
temp.write(file.download)
temp.flush
temp.chmod(mode)
File.rename(temp.path, dest)
unless same_file_at_path?(sandbox_path, opened_stat)
File.unlink(dest)
raise ArgumentError, "Sandbox root changed during upload: #{sandbox_path}"
end
end
end
Placement.new(sandbox_name, sandbox_path.join(sandbox_name).to_s)
end
def directory_handle_path(directory)
stat = directory.stat
[ "/proc/self/fd/#{directory.fileno}", "/dev/fd/#{directory.fileno}" ].find do |path|
same_file?(File.stat(path), stat)
rescue Errno::ENOENT
false
end || raise(ArgumentError, "Platform cannot anchor writes to the opened sandbox directory")
end
def destination_mode(path)
stat = File.lstat(path)
return stat.mode & 0o777 if stat.file? && !stat.symlink?
0o666 & ~File.umask
rescue Errno::ENOENT
0o666 & ~File.umask
end
def same_file_at_path?(path, expected)
current = File.lstat(path)
current.directory? == expected.directory? && !current.symlink? && same_file?(current, expected)
rescue Errno::ENOENT
false
end
def same_file?(left, right)
left.dev == right.dev && left.ino == right.ino
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

52
opencode-rails.gemspec Normal file
View File

@@ -0,0 +1,52 @@
# 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.
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha9"
spec.add_runtime_dependency "marcel", "~> 1.0"
# 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

67
test/example_test.rb Normal file
View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
require "test_helper"
require "ripper"
class ExampleTest < Minitest::Test
PATH = File.expand_path("../examples/rails_integration.rb", __dir__)
SOURCE = File.read(PATH)
def test_example_parses
assert Ripper.sexp(SOURCE)
end
def test_example_uses_turn_collaborator_contracts
assert_includes SOURCE, "def perform(assistant_message, user_message)"
assert_includes SOURCE, "session_for: session"
assert_includes SOURCE, "system_context: ->(record)"
assert_includes SOURCE, "agent_name: ->(_record)"
assert_includes SOURCE, '{ "build" }'
assert_includes SOURCE, "def watch(reply)"
assert_includes SOURCE, "include Opencode::ReplyObserver"
assert_includes SOURCE, 'ActiveSupport::Notifications.instrument("assistant.#{name}", payload)'
refute_includes SOURCE, 'Opencode::Tracer.new(prefix: "opencode.")'
refute_includes SOURCE, ".order(:created_at).last"
end
def test_example_uses_reply_indexes_for_dom_identity
assert_includes SOURCE, 'target: "part_#{index}_message_#{@message.id}"'
assert_includes SOURCE, "locals: { part: part, index: index, message: @message }"
refute_includes SOURCE, "part['id']"
end
def test_example_does_not_claim_unimplemented_mid_stream_persistence
refute_includes SOURCE, "Mid-stream parts_json snapshotting"
refute_includes SOURCE, "update_columns"
end
def test_example_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, SOURCE)
assert_includes SOURCE, 'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes SOURCE, "directory: working_directory"
assert_includes SOURCE, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes SOURCE, 'action: "allow"'
assert_includes SOURCE, "OPENCODE_DISABLE_PROJECT_CONFIG=1"
refute_includes SOURCE, "ConversationSandbox"
assert_includes SOURCE, "permissions only when it creates a session"
assert_match(/recreate persisted sessions/i, SOURCE)
refute_includes SOURCE, "Sandbox:"
end
def test_example_permission_order_denies_every_tool
body = SOURCE[/def permission_rules_for\(_conversation\)\n(?<body>.*?)^ end/m, :body]
rules = eval(body, binding, PATH)
action_for = lambda do |permission, pattern|
rules.reverse.find do |rule|
(rule.fetch(:permission) == "*" || rule.fetch(:permission) == permission) &&
(rule.fetch(:pattern) == "*" || rule.fetch(:pattern) == pattern)
end.fetch(:action)
end
assert_equal "deny", action_for.call("read", "arbitrary/worktree/path")
assert_equal "deny", action_for.call("edit", "arbitrary/worktree/path")
assert_equal "deny", action_for.call("external_directory", "/outside/*")
assert_equal "deny", action_for.call("grep", "secret")
assert_equal "deny", action_for.call("lsp", "*")
end
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,96 @@
# frozen_string_literal: true
require "test_helper"
require "open3"
# 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
# Match the gem's own lib path, not merely any parent directory. GitHub's
# checkout layout nests Bundler under /opencode-rails/opencode-rails, so a
# broad directory-name assertion falsely classifies a bundled
# opencode-ruby source path as belonging to this gem.
GEM_SOURCE_PATTERN = lambda do |name, file|
%r{/#{Regexp.escape(name)}(?:-[^/]+)?/lib/opencode/#{Regexp.escape(file)}\.rb\z}
end
def test_session_constant_points_at_this_gem
location = Opencode::Session.instance_method(:initialize).source_location.first
expected = File.expand_path("../../lib/opencode/session.rb", __dir__)
assert_equal expected, File.expand_path(location),
"Expected Opencode::Session to be loaded from this opencode-rails checkout"
end
def test_client_constant_points_at_opencode_ruby
location = Opencode::Client.instance_method(:initialize).source_location.first
assert_match GEM_SOURCE_PATTERN.call("opencode-ruby", "client"), location,
"Expected Opencode::Client to come from opencode-ruby, got: #{location}"
refute_match GEM_SOURCE_PATTERN.call("opencode-rails", "client"), location,
"Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)"
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
def test_umbrella_require_loads_runtime_standard_libraries
root = File.expand_path("../..", __dir__)
script = <<~'RUBY'
require "opencode-rails"
abort "StringIO missing" unless defined?(StringIO)
abort "Marcel missing" unless defined?(Marcel::MimeType)
abort "FileUtils missing" unless defined?(FileUtils)
abort "Tempfile missing" unless defined?(Tempfile)
RUBY
_stdout, stderr, status = Open3.capture3(
Gem.ruby,
"-I#{File.join(root, "lib")}",
"-e",
script
)
assert status.success?, stderr
end
end

View File

@@ -0,0 +1,56 @@
# 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
Message = Struct.new(:id, keyword_init: true)
Sandbox = Struct.new(:entries, keyword_init: true) do
def exists? = true
def files(after: nil) = entries
end
SandboxEntry = Struct.new(:basename, :attached, keyword_init: true) do
def as_artifact
self.attached = true
raise "destination must not use the identity attachment path"
end
end
class Transform < Opencode::Transform
def source_filename = "source.json"
def destination_filename = "rendered.html"
end
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
def test_agent_authored_transform_destination_never_uses_default_attachment
entry = SandboxEntry.new(basename: "rendered.html", attached: false)
artifacts = Opencode::MessageArtifacts.new(
message: Message.new(id: 1),
feature: "test",
transforms: [ Transform.new ]
)
artifacts.attach_from(sandbox: Sandbox.new(entries: [ entry ]))
refute entry.attached
end
end

View File

@@ -0,0 +1,208 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "socket"
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-")
@outside_dir = Dir.mktmpdir("opencode-rails-sandbox-file-outside-")
@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)
FileUtils.remove_entry(@outside_dir) if @outside_dir && File.exist?(@outside_dir)
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
def test_content_rejects_a_file_replaced_by_an_outside_symlink
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
assert file.safe?
File.unlink(@path)
File.symlink(outside, @path)
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
def test_safe_rejects_a_hard_link
File.unlink(@path)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
File.link(outside, @path)
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
refute file.safe?
end
def test_content_rejects_a_hard_link_added_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside_link = File.join(@outside_dir, "linked-notes.md")
real_open = File.method(:open)
link_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.link(path, outside_link)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, link_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_an_inode_that_grows_past_the_cap_while_reading
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
)
real_open = File.method(:open)
grow_on_read = lambda do |path, *args, **kwargs, &block|
real_open.call(path, *args, **kwargs) do |opened|
proxy = Object.new
proxy.define_singleton_method(:stat) { opened.stat }
proxy.define_singleton_method(:read) do |length = nil|
real_open.call(path, "ab") { |writer| writer.write("x" * 100) }
opened.read(length)
end
block.call(proxy)
end
end
File.stub(:open, grow_on_read) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_a_fifo_swapped_in_immediately_before_open_without_blocking
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_open = File.method(:open)
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
File.mkfifo(path)
assert_kind_of Integer, mode
assert_operator mode & File::NONBLOCK, :>, 0
assert_operator mode & File::NOFOLLOW, :>, 0
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_a_symlink_swapped_in_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
real_open = File.method(:open)
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
File.symlink(outside, path)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_safe_rejects_a_socket_swapped_in_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_open = File.method(:open)
socket = nil
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
socket = UNIXServer.new(path)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
refute file.safe?
end
ensure
socket&.close
end
def test_safe_fails_closed_when_secure_open_flags_are_unavailable
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_const_defined = File.method(:const_defined?)
const_defined = lambda do |name, inherit = true|
name == :NOFOLLOW ? false : real_const_defined.call(name, inherit)
end
File.stub(:const_defined?, const_defined) do
refute file.safe?
end
end
def test_content_returns_binary_empty_string_for_a_zero_byte_file
File.write(@path, "")
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
)
assert_equal "".b, file.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,67 @@
# 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? and #owned_filenames 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_fails_closed_by_default
transform = FakeTransform.new
destination = Attachment.new(filename: "rendered.html")
refute transform.trusted?(destination)
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

210
test/opencode/turn_test.rb Normal file
View File

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

View File

@@ -0,0 +1,188 @@
# 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
AttachedFiles = Class.new(Array) do
def attached? = true
end
FakeUpload = Struct.new(:filename, :content_type, :content, keyword_init: true) do
def byte_size = content.bytesize
def download = content
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
def test_copies_an_upload_without_domain_specific_instructions
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
prompt = Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal "hello", File.binread(File.join(@tmpdir, "upload.txt"))
assert_includes prompt.text, "Read each file thoroughly before responding:"
refute_includes prompt.text, "legal claims"
refute_includes prompt.text, "reference materials"
end
def test_rejects_a_sibling_prefix_escape
sandbox = File.join(@tmpdir, "foo")
sibling = File.join(@tmpdir, "foobar")
FileUtils.mkdir_p(sibling)
target = File.join(sibling, "target.txt")
File.write(target, "safe")
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
assert_raises(ArgumentError) do
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "../foobar/target.txt" }
)
end
assert_equal "safe", File.read(target)
end
def test_atomically_replaces_a_destination_symlink_without_following_it
outside = File.join(@tmpdir, "outside.txt")
sandbox = File.join(@tmpdir, "sandbox")
FileUtils.mkdir_p(sandbox)
File.write(outside, "safe")
File.symlink(outside, File.join(sandbox, "upload.txt"))
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal "safe", File.read(outside)
assert_equal "overwritten", File.read(File.join(sandbox, "upload.txt"))
refute File.symlink?(File.join(sandbox, "upload.txt"))
end
def test_retried_copy_refreshes_an_existing_sandbox_file
sandbox = File.join(@tmpdir, "sandbox")
FileUtils.mkdir_p(sandbox)
File.write(File.join(sandbox, "upload.txt"), "stale")
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal "fresh", File.read(File.join(sandbox, "upload.txt"))
end
def test_rejects_a_sandbox_root_replaced_during_the_copy
sandbox = File.join(@tmpdir, "sandbox")
moved_sandbox = File.join(@tmpdir, "moved-sandbox")
FileUtils.mkdir_p(sandbox)
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "private")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
create = Tempfile.method(:create)
replace_root = lambda do |*args, **kwargs, &block|
File.rename(sandbox, moved_sandbox)
FileUtils.mkdir_p(sandbox)
create.call(*args, **kwargs, &block)
end
Tempfile.stub(:create, replace_root) do
assert_raises(ArgumentError) do
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "upload.txt" }
)
end
end
refute File.exist?(File.join(sandbox, "upload.txt"))
refute File.exist?(File.join(moved_sandbox, "upload.txt"))
end
def test_new_upload_uses_the_normal_umask_file_mode
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal 0o666 & ~File.umask, File.stat(File.join(@tmpdir, "upload.txt")).mode & 0o777
end
def test_retried_copy_preserves_an_existing_file_mode
existing = File.join(@tmpdir, "upload.txt")
File.write(existing, "stale")
File.chmod(0o640, existing)
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal 0o640, File.stat(existing).mode & 0o777
end
end

82
test/readme_test.rb Normal file
View File

@@ -0,0 +1,82 @@
# frozen_string_literal: true
require "test_helper"
require "ripper"
class ReadmeTest < Minitest::Test
README_PATH = File.expand_path("../README.md", __dir__)
GEMFILE_PATH = File.expand_path("../Gemfile", __dir__)
def setup
@readme = File.read(README_PATH)
@quickstart = @readme[/^## Quickstart\n(?<body>.*?)(?=^## )/m, :body]
refute_nil @quickstart, "README must retain a Quickstart section"
end
def test_quickstart_turn_call_documents_every_required_keyword
turn_call = @quickstart[/Opencode::Turn\.new\((?<args>.*?)^\s*\)\.call/m, :args]
refute_nil turn_call, "Quickstart must contain an Opencode::Turn.new(...).call example"
documented = turn_call.scan(/^\s*([a-z_]+):/).flatten.map(&:to_sym)
parameters = Opencode::Turn.instance_method(:initialize).parameters
required = parameters.filter_map { |kind, name| name if kind == :keyreq }
accepted = parameters.filter_map { |kind, name| name if %i[keyreq key].include?(kind) }
assert_empty required - documented,
"Quickstart is missing required Turn keywords: #{(required - documented).join(", ")}"
assert_empty documented - accepted,
"Quickstart uses unsupported Turn keywords: #{(documented - accepted).join(", ")}"
refute_includes documented, :session
end
def test_readme_ruby_fences_parse
ruby_fences = @readme.scan(/```ruby\n(.*?)```/m).flatten
refute_empty ruby_fences
ruby_fences.each_with_index do |source, index|
assert Ripper.sexp(source), "README Ruby fence #{index + 1} has invalid syntax"
end
end
def test_install_contract_tracks_candidate_versions_and_client_source
gemfile = File.read(GEMFILE_PATH)
client_ref = gemfile[/gem "opencode-ruby".*?ref:\s*"([0-9a-f]{40})"/m, 1]
refute_nil client_ref, "Gemfile must pin opencode-ruby to an exact commit"
assert_includes @readme, %(gem "opencode-ruby", "= #{Opencode::VERSION}")
assert_includes @readme, %(gem "opencode-rails", "= #{Opencode::RAILS_VERSION}")
assert_includes @readme, %(ref: "#{client_ref}")
assert_includes @readme,
"`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate"
assert_match(/pushing a\s+`v\*` tag does not guarantee publication/, @readme)
assert_includes @readme, "pending trusted publisher for gem `opencode-rails`"
assert_includes @readme, "workflow `release.yml`"
assert_includes @readme, "environment `release`"
end
def test_quickstart_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, @quickstart)
assert_includes @quickstart,
'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes @quickstart, "directory: working_directory"
assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes @quickstart, 'action: "allow"'
refute_includes @quickstart, "ConversationSandbox"
assert_includes @readme, "same absolute path in Rails and OpenCode"
assert_includes @readme, "`OPENCODE_DISABLE_PROJECT_CONFIG=1`"
assert_includes @readme, "before session permissions exist"
assert_match(/intentionally grants no\s+filesystem tools/, @readme)
assert_includes @readme, "permissions only when it creates a session"
assert_includes @readme, "recreate persisted sessions"
end
def test_quickstart_uses_the_enqueued_user_message
assert_includes @quickstart, "def perform(assistant_message, user_message)"
refute_includes @quickstart, ".where(role: :user).last"
end
def test_instrumentation_docs_match_the_configured_tracer_prefix
assert_includes @readme, "`assistant.response.started`"
assert_includes @readme, "Turn events flow through the injected tracer"
end
end

View File

@@ -0,0 +1,82 @@
# frozen_string_literal: true
require "minitest/autorun"
require "yaml"
class ReleaseWorkflowTest < Minitest::Test
ROOT = File.expand_path("..", __dir__)
WORKFLOW_PATH = File.join(ROOT, ".github", "workflows", "release.yml")
SETUP_RUBY_ACTION = "ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600"
RELEASE_GEM_ACTION = "rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092"
def workflow
@workflow ||= YAML.safe_load(File.read(WORKFLOW_PATH), aliases: false)
end
def push_job
workflow.fetch("jobs").fetch("push")
end
def verify_job
workflow.fetch("jobs").fetch("verify")
end
def test_release_job_is_inert_on_non_github_runners
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
end
def test_release_job_keeps_the_trusted_publisher_boundary
assert_equal "release", push_job.fetch("environment")
assert_equal(
{ "contents" => "write", "id-token" => "write" },
push_job.fetch("permissions")
)
steps = push_job.fetch("steps")
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
assert_equal "4.0", setup_ruby.dig("with", "ruby-version")
assert_equal true, setup_ruby.dig("with", "bundler-cache")
assert_equal 1, steps.count { |step| step["uses"] == RELEASE_GEM_ACTION }
assert steps.filter_map { |step| step["uses"] }.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) }
refute steps.any? { |step| step.key?("run") }
end
def test_release_tag_must_match_the_gem_version_before_publish
preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" }
refute_nil preflight
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
end
def test_verification_job_has_read_only_credentials
assert_equal({ "contents" => "read" }, verify_job.fetch("permissions"))
checkout = verify_job.fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
assert_equal false, checkout.dig("with", "persist-credentials")
end
def test_release_verifies_the_supported_matrix_and_installed_gems_before_publish
assert_equal %w[3.2 3.3 3.4 4.0], verify_job.dig("strategy", "matrix", "ruby")
assert_equal "verify", push_job.fetch("needs")
commands = verify_job.fetch("steps").filter_map { |step| step["run"] }.join("\n")
assert_includes commands, "bundle exec rake test"
assert_includes commands, "gem build opencode-rails.gemspec"
assert_includes commands, "bundle exec ruby -ropencode/version"
assert_includes commands, 'GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"'
assert_includes commands, 'GEM_PATH="${GEM_HOME}"'
refute_includes commands, "Gem.path.join"
assert_includes commands, 'client_gem="$registry_dir/opencode-ruby-${client_version}.gem"'
assert_includes commands, 'rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version'
assert_includes commands, 'gem fetch opencode-ruby --version "$client_version" --prerelease'
assert_includes commands, "--clear-sources --source https://rubygems.org"
assert_includes commands,
'gem install "$client_gem" --no-document --clear-sources --source https://rubygems.org'
assert_includes commands,
'gem install "$rails_gem" --no-document --clear-sources --source https://rubygems.org'
assert_includes commands, "Gem.loaded_specs.fetch(name).full_gem_path"
assert_includes commands, "ruby -ropencode-rails"
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"

16
test/version_test.rb Normal file
View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
require "test_helper"
class Opencode::VersionTest < Minitest::Test
GEMSPEC_PATH = File.expand_path("../opencode-rails.gemspec", __dir__)
def test_alpha_versions_and_runtime_dependency_stay_in_lockstep
specification = Gem::Specification.load(GEMSPEC_PATH)
dependency = specification.runtime_dependencies.find { |item| item.name == "opencode-ruby" }
refute_nil dependency
assert_equal Opencode::RAILS_VERSION, Opencode::VERSION
assert_equal "= #{Opencode::RAILS_VERSION}", dependency.requirement.to_s
end
end

View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
require "minitest/autorun"
require "yaml"
class WorkflowContractTest < Minitest::Test
ROOT = File.expand_path("..", __dir__)
WORKFLOW_DIRECTORY = File.join(ROOT, ".github", "workflows")
TEST_WORKFLOW_PATH = File.join(WORKFLOW_DIRECTORY, "test.yml")
ACTION_PINS = {
"actions/checkout" => "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
"ruby/setup-ruby" => "003a5c4d8d6321bd302e38f6f0ec593f77f06600",
"rubygems/release-gem" => "052cc82692552de3ef2b81fd670e41d13cba8092"
}.freeze
def test_matrix_covers_every_supported_ruby
workflow = YAML.safe_load(File.read(TEST_WORKFLOW_PATH), aliases: false)
versions = workflow.dig("jobs", "test", "strategy", "matrix", "ruby")
assert_equal %w[3.2 3.3 3.4 4.0], versions
end
def test_every_third_party_action_uses_its_reviewed_commit
action_uses = Dir[File.join(WORKFLOW_DIRECTORY, "*.{yml,yaml}")].sort.flat_map do |path|
workflow = YAML.safe_load(File.read(path), aliases: false)
workflow_uses(workflow)
end
assert_equal 7, action_uses.length
action_uses.each do |action_use|
action, separator, revision = action_use.rpartition("@")
assert_equal "@", separator
assert_equal ACTION_PINS.fetch(action), revision
assert_match(/\A[0-9a-f]{40}\z/, revision)
end
end
def test_action_discovery_only_reads_workflow_action_locations
workflow = YAML.safe_load(<<~YAML, aliases: false)
jobs:
reusable:
uses: "owner/workflow@revision"
with:
uses: ordinary-job-input
test:
steps:
- uses: "owner/action@revision"
with:
uses: ordinary-step-input
YAML
assert_equal %w[owner/workflow@revision owner/action@revision], workflow_uses(workflow)
end
private
def workflow_uses(node)
node.fetch("jobs").values.flat_map do |job|
action_uses = job.key?("uses") ? [job.fetch("uses")] : []
step_uses = job.fetch("steps", []).filter_map { |step| step["uses"] }
action_uses.concat(step_uses)
end
end
end