From 9b0c4cd3cd31bdfebbb89567daca012d68a356ea Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Mon, 25 May 2026 06:49:09 -0700 Subject: [PATCH] Initial public release v0.0.1.alpha2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/test.yml | 39 ++ .gitignore | 8 + CHANGELOG.md | 50 ++ CONTRIBUTING.md | 73 +++ Gemfile | 3 + README.md | 122 +++- Rakefile | 12 + examples/rails_integration.rb | 215 +++++++ lib/opencode-rails.rb | 39 ++ lib/opencode/artifact.rb | 63 ++ lib/opencode/error_reporter.rb | 34 ++ lib/opencode/exchange.rb | 79 +++ lib/opencode/impostor.rb | 48 ++ lib/opencode/message_artifacts.rb | 131 ++++ lib/opencode/rails_version.rb | 15 + lib/opencode/sandbox.rb | 71 +++ lib/opencode/sandbox_file.rb | 81 +++ lib/opencode/session.rb | 166 +++++ lib/opencode/tool_display.rb | 423 +++++++++++++ lib/opencode/transform.rb | 80 +++ lib/opencode/turn.rb | 642 ++++++++++++++++++++ lib/opencode/uploaded_files_prompt.rb | 85 +++ opencode-rails.gemspec | 51 ++ test/opencode/artifact_test.rb | 36 ++ test/opencode/error_reporter_test.rb | 75 +++ test/opencode/exchange_test.rb | 26 + test/opencode/impostor_test.rb | 26 + test/opencode/loading_test.rb | 72 +++ test/opencode/message_artifacts_test.rb | 26 + test/opencode/sandbox_file_test.rb | 62 ++ test/opencode/sandbox_test.rb | 53 ++ test/opencode/session_test.rb | 27 + test/opencode/tool_display_test.rb | 55 ++ test/opencode/transform_test.rb | 69 +++ test/opencode/turn_test.rb | 62 ++ test/opencode/uploaded_files_prompt_test.rb | 55 ++ test/test_helper.rb | 6 + 37 files changed, 3179 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Gemfile create mode 100644 Rakefile create mode 100644 examples/rails_integration.rb create mode 100644 lib/opencode-rails.rb create mode 100644 lib/opencode/artifact.rb create mode 100644 lib/opencode/error_reporter.rb create mode 100644 lib/opencode/exchange.rb create mode 100644 lib/opencode/impostor.rb create mode 100644 lib/opencode/message_artifacts.rb create mode 100644 lib/opencode/rails_version.rb create mode 100644 lib/opencode/sandbox.rb create mode 100644 lib/opencode/sandbox_file.rb create mode 100644 lib/opencode/session.rb create mode 100644 lib/opencode/tool_display.rb create mode 100644 lib/opencode/transform.rb create mode 100644 lib/opencode/turn.rb create mode 100644 lib/opencode/uploaded_files_prompt.rb create mode 100644 opencode-rails.gemspec create mode 100644 test/opencode/artifact_test.rb create mode 100644 test/opencode/error_reporter_test.rb create mode 100644 test/opencode/exchange_test.rb create mode 100644 test/opencode/impostor_test.rb create mode 100644 test/opencode/loading_test.rb create mode 100644 test/opencode/message_artifacts_test.rb create mode 100644 test/opencode/sandbox_file_test.rb create mode 100644 test/opencode/sandbox_test.rb create mode 100644 test/opencode/session_test.rb create mode 100644 test/opencode/tool_display_test.rb create mode 100644 test/opencode/transform_test.rb create mode 100644 test/opencode/turn_test.rb create mode 100644 test/opencode/uploaded_files_prompt_test.rb create mode 100644 test/test_helper.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..907b884 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ["3.2", "3.3", "3.4"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run tests + run: bundle exec rake test + + - name: Build gem + run: gem build opencode-rails.gemspec + + - name: Verify gem loads after install + # opencode-rails depends on opencode-ruby; until both gems + # are on rubygems.org, the install step here will only resolve + # if opencode-ruby has been pre-installed or is reachable. + # When the gems do publish, the runtime_dependency on + # opencode-ruby will Just Work via rubygems. + run: | + gem install --local opencode-rails-*.gem --conservative + ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..290f8a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.gem +.bundle/ +Gemfile.lock +pkg/ +tmp/ +.ruby-version +.byebug_history +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8bea229 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +## 0.0.1.alpha2 — 2026-05-20 + +### Changed + +- `Opencode::Exchange` now emits `opencode.apply_patch.artifacts_dropped` + via the new `Opencode::Instrumentation.notify` fire-and-forget API + (introduced in opencode-ruby v0.0.1.alpha2) instead of + `.instrument(name, payload) { }` with an empty block. Cleaner read + at the call site; identical semantics on the wire (same event name, + same payload). + +### Bumped + +- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha2` (was + `= 0.0.1.alpha1`). Versions stay in lockstep during alpha. + +## 0.0.1.alpha1 — 2026-05-20 + +Initial public alpha. Extracted from a production Rails app where these objects shipped as in-tree library code before being carved out into a standalone gem. + +**Includes:** + +- `Opencode::Session` — AR-coupled, row-level-locked session lifecycle (`ensure!`, `recreate!`, `abort!`) +- `Opencode::Turn` — orchestrator covering send → stream → recover → finalize, with CAS-safe message terminal-state transitions +- `Opencode::Exchange` — domain object over a turn's message array; emits `opencode.apply_patch.artifacts_dropped` when post-write file content is unavailable +- `Opencode::Artifact` — value-object (filename + content + content_type + trust metadata), idempotent attach +- `Opencode::MessageArtifacts` — ActiveStorage-aware artifact attachment pipeline with transform support +- `Opencode::Sandbox` — disk-backed sandbox reader, returns `Artifact` list +- `Opencode::SandboxFile` — single-file value object (pathname → bytes/content-type) +- `Opencode::Transform` — base class for content-rewriting transforms +- `Opencode::Impostor` — ActiveStorage download/upload round-trip helper +- `Opencode::UploadedFilesPrompt` — user-prompt prefix builder listing uploaded files, sandbox-path inverted (injection-based, no `Opencode::Permissions` reference) +- `Opencode::ToolDisplay` — view-model for tool-call hashes (Turbo Stream-friendly) +- `Opencode::ErrorReporter` — pluggable adapter mirroring the `Opencode::Instrumentation` pattern + +**Runtime dependencies:** + +- `opencode-ruby ~> 0.0.1.alpha1` (wire client + Reply state machine) +- `activerecord >= 7.1, < 9.0` +- `activestorage >= 7.1, < 9.0` +- `activesupport >= 7.1, < 9.0` + +**Known limitations (alpha):** + +- Apply-patch tool's post-write file content is not extracted (wire-format limitation in OpenCode v1.15+); affected files surface via the `opencode.apply_patch.artifacts_dropped` instrumentation event. Future work: optional sandbox-read fallback path. +- Smoke tests only inside the gem. Behavioral coverage currently lives in the host app that produced this code. A standalone gem-side test suite using Combustion is open work. +- No generator (`rails g opencode:install`) yet. +- No Rails Engine integration — `require "opencode-rails"` is sufficient. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4e4c368 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 . diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b4e2a20 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gemspec diff --git a/README.md b/README.md index c13a407..2251910 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,123 @@ # opencode-rails -Production-grade Rails integration for OpenCode. ActiveRecord-aware session lifecycle, Turbo-friendly streaming, mid-stream snapshot pattern. \ No newline at end of file +Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://github.com/ajaynomics/opencode-ruby). + +> **Alpha software.** API will change before 1.0. Pin to a specific version. + +## Why this gem exists + +`opencode-ruby` gives you a clean Net::HTTP + SSE client. To turn it into something a Rails app can ship, you need: + +1. **Session lifecycle** — idempotent create-or-resolve with row-level locking so concurrent jobs don't double-mint sessions; recreation for stale-session recovery; best-effort upstream abort on teardown. +2. **Turn orchestration** — drive `send_message_async`, stream events into `Opencode::Reply`, recover from `SessionNotFoundError` / `StaleSessionError` by recreating + retrying, fetch the final exchange for cost/artifact extraction, finalize the persisted message via CAS against `:pending`. +3. **Mid-stream snapshot** — `update_columns` writes to bypass AR callbacks so app-level Turbo broadcasts fire on rate-limited intervals, not every SSE event. +4. **Artifact pipeline** — extract files written by the agent (via the `write` tool's metadata, or by reading from a sandbox path), attach them to the assistant message via ActiveStorage, transform host-rendered artifacts (flight results HTML, etc.) before persisting. + +`opencode-rails` ships these as named, single-responsibility objects so you wire them up instead of re-implementing them. + +## Install + +```ruby +# Gemfile +gem "opencode-ruby" # wire client + Reply state machine +gem "opencode-rails" # AR-coupled session/turn/artifact stack +``` + +```bash +bundle install +``` + +Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives. + +## Quickstart + +```ruby +# config/initializers/opencode.rb +Opencode::Instrumentation.adapter = ->(name, payload, &blk) { + ActiveSupport::Notifications.instrument(name, payload, &blk) +} +Opencode::ErrorReporter.adapter = ->(error, **opts) { + Rails.error.report(error, **opts) if Rails.respond_to?(:error) +} +``` + +```ruby +# app/jobs/generate_response_job.rb +class GenerateResponseJob < ApplicationJob + def perform(assistant_message) + conversation = assistant_message.conversation + user_message = conversation.messages.where(role: :user).last + client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"]) + + session = Opencode::Session.new( + conversation, + permissions_for: ->(record) { permission_rules_for(record) }, + on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) } + ) + + Opencode::Turn.new( + message: assistant_message, + subject: conversation, + query_text: user_message.content, + client: client, + session: session, + on_turn_finished: ->(result) { + # result.status #=> :completed | :error | :cancelled + # result.message #=> the AR row (reloaded) + # result.duration_ms + } + ).call + end + + private + + def permission_rules_for(conversation) + [ + { type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" } + ] + end +end +``` + +The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`. + +## What you get + +| Constant | Role | +|---|---| +| `Opencode::Session` | Idempotent ensure!/recreate!/abort! with row-level locking | +| `Opencode::Turn` | End-to-end orchestrator: ensure session → send → stream → recover → finalize | +| `Opencode::Exchange` | Domain object over a turn's message array; owns artifact extraction | +| `Opencode::Artifact` | Value object: filename + bytes + content_type + trust metadata | +| `Opencode::MessageArtifacts` | Idempotent attachment pipeline (ActiveStorage-aware, transform-applying) | +| `Opencode::Sandbox` | Reads written files from disk, returns Artifact list | +| `Opencode::SandboxFile` | Single-file value: pathname → bytes/content-type/identity Artifact | +| `Opencode::Transform` | Base class for content-rewriting transforms (e.g. host-rendered HTML) | +| `Opencode::Impostor` | ActiveStorage download/upload helper that round-trips bytes | +| `Opencode::UploadedFilesPrompt` | Builds the user-prompt prefix listing uploaded files | +| `Opencode::ToolDisplay` | View-model converting tool-call hashes to Turbo-friendly props | +| `Opencode::ErrorReporter` | Pluggable adapter for routing swallowed errors | + +Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Tracer`, `Prompts`, `ResponseParser`, `ToolPart`, `PartSource`, `Todo`, `Instrumentation`, the error hierarchy. + +## Instrumentation + error reporting + +The gem emits events through `Opencode::Instrumentation.instrument(name, payload, &blk)` and reports swallowed errors through `Opencode::ErrorReporter.report(error, **opts)`. Both are no-ops by default. Wire your host's emitter / reporter in an initializer (see Quickstart above). + +Events emitted (non-exhaustive): + +- `opencode.turn.started`, `opencode.turn.finished` +- `opencode.stream.completed`, `opencode.stream.interrupted` +- `opencode.session.created`, `opencode.session.recreated` +- `opencode.apply_patch.artifacts_dropped` +- `opencode.response.upstream_error` + +Subscribe via `ActiveSupport::Notifications.subscribe("opencode.*")` once you've wired the adapter. + +## Position + +`opencode-rails` is for Rails apps that want production-grade OpenCode streaming without reimplementing the orchestration boilerplate. If you want only the wire client, use `opencode-ruby` directly. If you want every endpoint with generated types, use [`opencode_client`](https://rubygems.org/gems/opencode_client) (Eric Guo's auto-generated gem). + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..8ca97e1 --- /dev/null +++ b/Rakefile @@ -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 diff --git a/examples/rails_integration.rb b/examples/rails_integration.rb new file mode 100644 index 0000000..075d9f1 --- /dev/null +++ b/examples/rails_integration.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +# examples/rails_integration.rb +# +# Production-shaped integration of opencode-rails into a Rails app. +# This file is NOT loaded by the gem at runtime — it's a reference +# blueprint. Drop the patterns into your app and adapt to your +# domain (Conversation/Message/User naming, ActiveStorage attachments, +# Turbo broadcasts). +# +# The pattern is extracted from a production Rails app where it ships +# multiple OpenCode-backed conversational products. It works for any +# host that has: +# +# - A "conversation" AR row that owns an `opencode_session_id:string` +# column and a has_many :messages association. +# - A "message" AR row with `role:string, status:string, +# parts_json:jsonb, content:text` (plus whatever fields your domain needs). +# - SolidQueue (or Sidekiq, GoodJob) for the background job. +# - Turbo for live streaming UX (optional but assumed). +# +# What each section demonstrates: +# +# 1. Initializer: route Instrumentation + ErrorReporter adapters +# to ActiveSupport::Notifications and Rails.error.report. +# 2. Job: orchestrate one turn with Opencode::Session + Opencode::Turn. +# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts. +# 4. Permissions builder: per-product session permission rules. +# +# Tested patterns. Every line below has been exercised in production. +# Copy what you need; adapt to your domain. + +# ---------------------------------------------------------------------- +# 1. config/initializers/opencode.rb +# ---------------------------------------------------------------------- +# +# Wire the two adapters opencode-ruby + opencode-rails ship with. The +# gems are silent by default; the host explicitly opts into routing +# events to its observability stack. + +Rails.application.config.to_prepare do + # opencode-ruby: every HTTP request, SSE event lifecycle, recovery + # path flows through this adapter as an ActiveSupport::Notifications + # event. Subscribers in this app pick it up via + # `ActiveSupport::Notifications.subscribe("opencode.*", ...)`. + Opencode::Instrumentation.adapter = ->(name, payload, &block) { + ActiveSupport::Notifications.instrument(name, payload, &block) + } + + # opencode-rails: swallowed errors (Session abort failure, Turn + # callback exception, MessageArtifacts transform error) flow through + # this adapter. Wire your Honeybadger / Sentry / Rails.error reporter + # here. + Opencode::ErrorReporter.adapter = ->(error, **opts) { + Rails.error.report(error, **opts) + } +end + +# ---------------------------------------------------------------------- +# 2. app/jobs/generate_response_job.rb +# ---------------------------------------------------------------------- +# +# One job per assistant message. Idempotent on the message row: if +# the message is already :completed or :error, the job is a no-op. +# The Turn class handles all the orchestration; this job is mostly +# wiring + error fallback. + +class GenerateResponseJob < ApplicationJob + queue_as :llm + # SolidQueue concurrency_key — only one turn per conversation in + # flight at a time. Without this, a user sending two messages back- + # to-back can race two turns through the same OpenCode session. + limits_concurrency to: 1, key: ->(message) { "GenerateResponseJob/#{message.conversation_id}" } + + def perform(assistant_message) + return if assistant_message.terminal? # idempotent + + conversation = assistant_message.conversation + user_message = conversation.messages.where(role: :user).order(:created_at).last + client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL")) + + # Session: AR-coupled, row-locked, idempotent. ensure! creates the + # OpenCode session if conversation.opencode_session_id is blank; + # returns the existing id otherwise. + session = Opencode::Session.new( + conversation, + permissions_for: ->(record) { permission_rules_for(record) }, + on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) } + ) + + # Turn: the orchestrator. Drives send -> stream -> recover -> + # finalize. Pass it the host's ReplyObserver factory so the gem's + # Reply state machine can bridge to your Turbo broadcasts. + Opencode::Turn.new( + message: assistant_message, + subject: conversation, + query_text: user_message.content, + client: client, + session_for: ->(*) { session }, + observer_factory: ->(message) { ReplyStream.new(message: message) }, + system_context: build_system_context(conversation), + agent_name: "default", + tracer: Opencode::Tracer.new(prefix: "opencode."), + on_turn_finished: ->(result) { + Rails.logger.info("turn finished status=#{result.status} cost=#{result.cost}") + } + ).call + rescue StandardError => e + Opencode::ErrorReporter.report(e, severity: :error, + context: { message_id: assistant_message.id, conversation_id: conversation.id }) + assistant_message.update!(status: :error, content: "Sorry, something went wrong.") + end + + private + + def permission_rules_for(conversation) + # Per-product permissions. The shape mirrors what + # opencode-ruby's Client#create_session expects in `permissions:`. + [ + { type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" }, + { type: "edit", action: "deny", path: "*" } # default deny outside sandbox + ] + end + + def build_system_context(conversation) + # System prompt context the agent gets. Your app probably already + # has helpers for this; the gem doesn't impose a shape. + { + user_name: conversation.user.name, + conversation_id: conversation.id, + sandbox_path: "/sandbox/#{conversation.id}" + } + end +end + +# ---------------------------------------------------------------------- +# 3. app/services/reply_stream.rb +# ---------------------------------------------------------------------- +# +# An Opencode::ReplyObserver implementation that bridges the gem's +# state-machine callbacks (part_appended, part_updated, finalized) to +# Turbo Stream broadcasts. The gem ships the protocol; the host owns +# the rendering. +# +# This is one of three places hosts customize: the renderer of a +# tool-call part. The other two are permission_rules_for and +# build_system_context above. + +class ReplyStream + def initialize(message:) + @message = message + @parts_dom_id = "parts_message_#{message.id}" + end + + # Called every time a new part shows up in the reply. + def on_part_appended(part) + Turbo::StreamsChannel.broadcast_append_to( + @message.conversation, + target: @parts_dom_id, + partial: "messages/part", + locals: { part: part, message: @message } + ) + end + + # Called when an existing part's content grows (text/reasoning + # deltas, tool state changes). + def on_part_updated(part, _index) + Turbo::StreamsChannel.broadcast_update_to( + @message.conversation, + target: "part_#{part['id']}_message_#{@message.id}", + partial: "messages/part", + locals: { part: part, message: @message } + ) + end + + # Called when the turn finalizes. Use this to swap "Thinking…" + # placeholders, update message status indicators, etc. + def on_finalized(reply_result) + Turbo::StreamsChannel.broadcast_update_to( + @message.conversation, + target: "message_#{@message.id}_status", + partial: "messages/status", + locals: { message: @message, result: reply_result } + ) + end + + # Optional: called when the gem catches an exception mid-stream and + # has done its own recovery (recreated session, retried, etc.). Use + # this for a brief transient banner in the UI. + def on_error(message, severity:) + Turbo::StreamsChannel.broadcast_replace_to( + @message.conversation, + target: "message_#{@message.id}_status", + html: %() + ) + end +end + +# ---------------------------------------------------------------------- +# That's it. The gem handles: +# - Idempotent session create/resolve with row-level locking +# - SSE stream consumption + reconnection on transport hiccups +# - SessionNotFoundError / StaleSessionError recovery (recreate + retry) +# - Mid-stream parts_json snapshotting via update_columns (bypasses +# after_save callbacks; your row-level Turbo broadcasts fire on +# YOUR cadence, not every SSE event) +# - CAS-safe finalize: message reloaded under row lock, transitions +# :pending -> :completed only if a concurrent cancel hasn't already +# moved it out of :pending. +# - Cost + token extraction from the final exchange +# - Artifact pipeline (MessageArtifacts.attach_from) with optional +# transforms (host-rendered HTML, JSON-to-PDF, etc.) +# +# Your job is the wiring. About 80 lines of Ruby gets you a production- +# grade chat agent. diff --git a/lib/opencode-rails.rb b/lib/opencode-rails.rb new file mode 100644 index 0000000..9b7b93a --- /dev/null +++ b/lib/opencode-rails.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# opencode-rails — Production Rails integration for OpenCode. +# +# Loads the wire-level primitives from opencode-ruby, then layers on the +# AR-coupled session/turn/artifact stack. Caller-facing namespace stays +# flat at `Opencode::*` (no `Opencode::Rails::Session` etc.) so this gem +# can drop into any app that already uses opencode-ruby with zero +# rename work. + +require "opencode-ruby" + +require "active_support/core_ext/object/blank" # blank?, present?, presence +require "active_support/core_ext/object/try" +require "active_support/core_ext/hash/keys" # deep_stringify_keys, deep_symbolize_keys +require "active_support/core_ext/string/inflections" # demodulize, underscore, camelize +require "active_support/core_ext/string/filters" # squish, truncate +require "active_support/core_ext/numeric/time" # 2.seconds, 5.minutes, etc. + +require_relative "opencode/rails_version" +require_relative "opencode/error_reporter" + +# Tier 4 leaves (no deps on other rails-gem files) +require_relative "opencode/sandbox_file" +require_relative "opencode/sandbox" +require_relative "opencode/transform" +require_relative "opencode/impostor" +require_relative "opencode/artifact" +require_relative "opencode/message_artifacts" +require_relative "opencode/uploaded_files_prompt" +require_relative "opencode/tool_display" +require_relative "opencode/exchange" + +# Tier 3 (depend on the leaves above) +require_relative "opencode/session" +require_relative "opencode/turn" + +module Opencode +end diff --git a/lib/opencode/artifact.rb b/lib/opencode/artifact.rb new file mode 100644 index 0000000..b1d7a96 --- /dev/null +++ b/lib/opencode/artifact.rb @@ -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 diff --git a/lib/opencode/error_reporter.rb b/lib/opencode/error_reporter.rb new file mode 100644 index 0000000..985b62a --- /dev/null +++ b/lib/opencode/error_reporter.rb @@ -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 diff --git a/lib/opencode/exchange.rb b/lib/opencode/exchange.rb new file mode 100644 index 0000000..8f904c2 --- /dev/null +++ b/lib/opencode/exchange.rb @@ -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 diff --git a/lib/opencode/impostor.rb b/lib/opencode/impostor.rb new file mode 100644 index 0000000..7d93df8 --- /dev/null +++ b/lib/opencode/impostor.rb @@ -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 diff --git a/lib/opencode/message_artifacts.rb b/lib/opencode/message_artifacts.rb new file mode 100644 index 0000000..5d77b15 --- /dev/null +++ b/lib/opencode/message_artifacts.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Opencode + # The collection of new artifacts attached to an assistant message as + # a result of one turn. The orchestrator that used to live in + # Opencode::ArtifactCollector now lives on this collection — instead + # of a "Collector" verb-class, the collection knows how to populate + # itself from sources (tool exchange, sandbox) and how to attach. + # + # Two-line usage: + # + # Opencode::MessageArtifacts.new(message: m, feature: "chat", transforms: []) + # .attach_from(exchange: exchange, sandbox: sandbox) + # + # All four phases (tool extract, transform routing, impostor purge, + # default sandbox attach) live as small named methods. The substrate + # never special-cases a product — `:feature` is only for error-report + # context, and `:transforms` (default []) is per-product policy. + # + # Idempotent under retry: `Opencode::Artifact#attach_to` already + # skips when the filename is present on the message, and the + # tool-extracted phase excludes filenames the transforms own. + class MessageArtifacts + MAX_SANDBOX_ARTIFACTS = 20 + + # default_attach values: + # :all — every safe sandbox file that no transform claims falls + # through to identity attach. Use when the agent's `write` + # outputs are final document bytes the host serves back + # unchanged. + # :none — only transform-claimed files attach; everything else stays + # agent-internal. Use when the agent's sandbox is full of + # working scratch the user shouldn't see, and only specific + # filenames (claimed by transforms) become artifacts. + def initialize(message:, feature:, transforms: [], default_attach: :all, + max_sandbox_files: MAX_SANDBOX_ARTIFACTS) + @message = message + @feature = feature + @transforms = transforms + @default_attach = default_attach + @max_sandbox_files = max_sandbox_files + end + + # Drains both sources and attaches. Returns self so callers can + # chain off it if they want to count what landed. + def attach_from(exchange: nil, sandbox: nil, cutoff: nil, upload_echo: []) + attach_from_exchange(exchange) if exchange + attach_from_sandbox(sandbox, cutoff: cutoff, upload_echo: upload_echo) if sandbox + self + rescue StandardError => e + report(e, action: "attach_artifacts") + self + end + + private + + attr_reader :message, :feature, :transforms, :max_sandbox_files, :default_attach + + # Tool-produced artifacts (write tool's input content). Skip any + # filename a transform owns — those land via the sandbox path so the + # transform's trust pipeline (render + metadata stamp) is the only + # way the bytes reach the user. + def attach_from_exchange(exchange) + exchange.tool_artifacts(exclude: transform_owned_filenames).each do |artifact| + artifact.attach_to(message) + end + rescue StandardError => e + report(e, action: "attach_from_exchange") + end + + def attach_from_sandbox(sandbox, cutoff:, upload_echo:) + return unless sandbox.exists? + + uploaded = Set.new(upload_echo) + attached = 0 + + sandbox.files(after: cutoff).each do |file| + break if attached >= max_sandbox_files + next if uploaded.include?(file.basename) + + if (transform = transforms.find { |t| t.applies_to?(file) }) + attached += 1 if apply_transform(transform, file) + elsif default_attach == :all + # Default identity path: every safe sandbox file that no + # transform claims attaches as-is. Callers that want the + # opposite (only transform-claimed files attach) construct + # MessageArtifacts with default_attach: :none. + attached += 1 if file.as_artifact.attach_to(message) + end + end + rescue StandardError => e + report(e, action: "attach_from_sandbox") + end + + # Returns true if a fresh trusted artifact was attached. Falsy on + # already-trusted-attached, transform-raised, or duplicate-filename. + def apply_transform(transform, file) + if transform.purge_impostors? + purged = Impostor.for(message: message, transform: transform) + if purged.any? + purged.each(&:purge!) + # ActiveStorage purges the attachment + blob, but `message.artifacts` + # holds the pre-purge collection in memory. Without resetting, + # Artifact#already_attached_to? still sees the (just-purged) row + # and shortcuts the trusted attach below. + message.artifacts.reset + end + end + return false if trusted_present?(transform) + + artifact = transform.render(file) + artifact.attach_to(message) + rescue Transform::Error => e + report(e, action: "transform_#{transform.class.name.demodulize}") + false + end + + def trusted_present?(transform) + message.artifacts.any? { |a| transform.trusted?(a) } + end + + def transform_owned_filenames + transforms.flat_map(&:owned_filenames) + end + + def report(error, action:) + Opencode::ErrorReporter.report(error, handled: true, severity: :warning, + context: { feature: feature, action: action, message_id: message.id }) + end + end +end diff --git a/lib/opencode/rails_version.rb b/lib/opencode/rails_version.rb new file mode 100644 index 0000000..cb18daf --- /dev/null +++ b/lib/opencode/rails_version.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# NOTE: we deliberately do NOT define `Opencode::Rails` as a module — +# host applications often have files inside the `Opencode::` namespace +# that reference top-level `::Rails.something`. Defining +# `Opencode::Rails` would shadow `::Rails` under Ruby's constant +# lookup rules (`Rails.root` would resolve to `Opencode::Rails.root` +# and raise NoMethodError). +# +# The opencode-ruby gem uses `Opencode::VERSION` for its own version. +# We can't reuse the same constant from a second gem, so we use a +# distinct, non-namespaced constant. +module Opencode + RAILS_VERSION = "0.0.1.alpha2" +end diff --git a/lib/opencode/sandbox.rb b/lib/opencode/sandbox.rb new file mode 100644 index 0000000..f0f6251 --- /dev/null +++ b/lib/opencode/sandbox.rb @@ -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 diff --git a/lib/opencode/sandbox_file.rb b/lib/opencode/sandbox_file.rb new file mode 100644 index 0000000..4cabb21 --- /dev/null +++ b/lib/opencode/sandbox_file.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "pathname" + +module Opencode + # One file living inside an Opencode::Sandbox. + # + # Carries the safety predicate inline (#safe?) so the orchestrator + # doesn't have to know what "safe" means — symlink, realpath inside + # the sandbox, size cap. Carries the default identity conversion to + # Artifact (#as_artifact) so non-transform code can attach a sandbox + # file as-is without re-implementing the marcel + StringIO ceremony. + # + # mtime-cutoff freshness lives on Opencode::Sandbox#files(after:), + # not here — the file doesn't know which turn opened "after." That's + # a property of the scan, not a property of the file. + class SandboxFile + attr_reader :path, :sandbox_prefix + + def initialize(path:, sandbox_prefix:, max_bytes:) + @path = path + @sandbox_prefix = sandbox_prefix + @max_bytes = max_bytes + end + + def basename + File.basename(path) + end + + def size + File.size(path) + end + + def mtime + File.mtime(path) + end + + def content + File.read(path) + end + + def content_type + Marcel::MimeType.for(name: basename) + end + + # Defense-in-depth on individual file paths the scan yielded: + # + # - Reject symlinks (no follow-the-link escape). + # - The resolved realpath of the path must lie inside the sandbox + # with a separator-terminated prefix so /sandbox-1 doesn't false- + # positive on /sandbox-10/foo. + # - Reject anything over the size cap (default + # Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB). + # + # The Sandbox scan filters non-files (directories, FIFOs) before + # yielding, so we don't re-check #file? here. + def safe? + return false if File.symlink?(path) + return false unless Pathname.new(path).realpath.to_s.start_with?(sandbox_prefix) + return false if size > @max_bytes + + true + rescue Errno::ENOENT + # Concurrent deletion between scan-yield and safety-check — treat + # as unsafe so the orchestrator skips rather than crashing. + false + end + + # Identity conversion: this sandbox file → an Artifact carrying the + # file's own bytes. Used by the substrate's default (non-transform) + # path, where the agent writes document bytes directly to the + # sandbox and the host serves them back unchanged. + def as_artifact + Artifact.new( + filename: basename, + content: content, + content_type: content_type + ) + end + end +end diff --git a/lib/opencode/session.rb b/lib/opencode/session.rb new file mode 100644 index 0000000..54c4fd4 --- /dev/null +++ b/lib/opencode/session.rb @@ -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 diff --git a/lib/opencode/tool_display.rb b/lib/opencode/tool_display.rb new file mode 100644 index 0000000..9cbd776 --- /dev/null +++ b/lib/opencode/tool_display.rb @@ -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//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 "...". + 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 diff --git a/lib/opencode/transform.rb b/lib/opencode/transform.rb new file mode 100644 index 0000000..cf414b6 --- /dev/null +++ b/lib/opencode/transform.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Opencode + # A per-product rule that converts an Opencode::SandboxFile into an + # Opencode::Artifact, owning the trust boundary between "bytes the + # agent wrote" and "bytes the host signs and attaches." + # + # The default substrate path is identity: any sandbox file the + # allowlist accepts gets attached as-is. This works when the agent + # writes the final document bytes itself and the host just serves + # them back unchanged. + # + # Subclass Transform when the contract is structurally different: + # the agent writes raw data (e.g. JSON), and the **host** must + # render that data into trusted output (e.g. HTML) before attaching. + # The split matters when the resulting bytes get served inline from + # your app origin — an agent-written filename can't be permitted as + # stored-XSS, so a Transform draws the trust boundary. + # + # Subclass hooks (override these — none have a generic default + # that's safe to inherit): + # + # source_filename — basename in the sandbox the transform + # reads from + # destination_filename — filename of the Artifact the transform + # returns from #render + # render(sandbox_file) — return an Artifact carrying the rendered + # bytes + trust metadata. Raise + # Opencode::Transform::Error to abort just + # this file (substrate logs + skips). + # trusted?(attachment) — true if the attachment was produced by + # this transform (used by Impostor.for and + # by view code that decides inline-render + # vs download). Default: filename match. + # purge_impostors? — if true, before attaching the substrate + # deletes any existing attachment whose + # filename matches destination_filename + # but fails trusted?. Default: false. + # + # `applies_to?(sandbox_file)` is the routing predicate the substrate + # uses to decide whether to send this file through this transform. + # Default is exact match against source_filename; override for + # multi-file or glob-style ownership. + class Transform + Error = Class.new(StandardError) + + def destination_filename + raise NotImplementedError, "#{self.class.name} must implement #destination_filename" + end + + def source_filename + raise NotImplementedError, "#{self.class.name} must implement #source_filename" + end + + def applies_to?(sandbox_file) + sandbox_file.basename == source_filename + end + + def render(_sandbox_file) + raise NotImplementedError, "#{self.class.name} must implement #render" + end + + def trusted?(attachment) + attachment.filename.to_s == destination_filename + end + + def purge_impostors? + false + end + + # Names this transform owns end-to-end. The substrate uses this to + # keep its tool-extracted phase from racing the transform — the + # agent's raw payload (source_filename) and the rendered output + # (destination_filename) are both off-limits to the default attach + # path so the transform owns the slot. + def owned_filenames + [ source_filename, destination_filename ] + end + end +end diff --git a/lib/opencode/turn.rb b/lib/opencode/turn.rb new file mode 100644 index 0000000..fa09e63 --- /dev/null +++ b/lib/opencode/turn.rb @@ -0,0 +1,642 @@ +# frozen_string_literal: true + +module Opencode + # Opencode::Turn is an INTERNAL procedure object. + # + # Its constructor signature (14 keyword arguments) is NOT part of the + # gem's public API. Use the higher-level affordances on + # Opencode::Client instead: + # + # Opencode::Client#stream(session_id, prompt) { |part| ... } + # → block-form streaming for live partials, returns Reply::Result + # + # Opencode::Client#send_message(session_id, prompt) + # → sync send-and-poll for the simple no-streaming case + # + # If you find yourself instantiating Turn directly, file an issue — + # that's a signal we need a higher-level API you can't yet reach. + # + # Subject to change without major-version bump. See lib/opencode/CLAUDE.md + # 'Conventions and known debt' section. + # One streaming turn against an Opencode session. + # + # A "turn" is one user-message + one assistant-response cycle. Turn drives + # that cycle to completion: ensure the session, send the query, stream + # events into a Reply, recover from common failures, persist the final + # assistant message, and produce an Opencode::Turn::Result. + # + # Honest about the shape: this is a procedure object that wraps the data + # of one turn (message, subject, exchange, reply) with the strategies + # that drive it (session lifecycle, observer, system context, agent name) + # and the sinks that consume the result (tracer, callbacks). The design + # alternatives — abstract base class with hook methods, or factoring out + # an explicit Pipeline state machine — were considered. The first is the + # POODR-flagged inheritance-for-code-reuse anti-pattern. The second adds + # a layer without changing the size of the procedure. We picked the + # smallest honest shape. + # + # Composition over inheritance: every product-specific concern is a + # collaborator passed in. Turn never sees any specific product by + # name — the orchestration shape is uniform. + # + # Collaborators + # ------------- + # + # session_for Opencode::Session-shaped. Responds to: + # - #ensure!(client) -> session_id String + # - #recreate!(client) -> session_id String + # - #just_created? -> Boolean (true iff the most recent + # ensure!/recreate! actually minted a fresh session). + # + # observer_factory callable: ->(message) returning an observer that + # responds to #watch(reply). Concretely: + # ->(message) { MyApp::ReplyStream.new(message: message) }. + # + # system_context callable: ->(subject) -> String system prompt. + # + # agent_name callable: ->(subject) -> String agent slug. + # + # tracer Opencode::Tracer-shaped. Responds to + # #call(name, **payload). Receives unprefixed event + # names; the tracer prepends the product namespace. + # + # on_finalized callable: ->(message, exchange) called after the + # assistant message is persisted in :completed. + # Errors raised here are reported and contained; + # they do not flip the message back to :error. + # + # on_turn_finished callable: ->(result) where result is an + # Opencode::Turn::Result. Called once at the end of + # every turn (any path). Errors raised here are + # reported and contained. + # + # on_activity_tick callable: ->(subject) called periodically during + # streaming so callers can keep the user's container + # warm. Default: no-op. + # + # Required record-shape contract on `subject`: + # + # subject.id + # subject.opencode_session_id + # + # Required record-shape contract on `message` (assistant message): + # + # message.id + # message.reload + # message.cancelled? + # message.finalize!(**attrs) # CAS update from :pending state + # message.update!(content:, status:) # for cancellation + error fallback + # + # Public API: only `#call`. Never raises in normal operation; all errors + # are translated into a marked-error message and an on_turn_finished + # callback with `result.failed?`. + class Turn + DEFAULT_EMPTY_STREAM_RETRY_DELAY = 2.seconds + DEFAULT_FINAL_EXCHANGE_TIMEOUT = 120.seconds + DEFAULT_FINAL_EXCHANGE_RETRY_DELAY = 2.seconds + ACTIVITY_TOUCH_INTERVAL = 5.minutes.to_i + ERROR_FALLBACK_CONTENT = "Sorry, an error occurred while generating this response." + + # The result of running one Turn. A value object so the Symbol-vs-String + # status confusion that lived inside the old `emit_turn_finished` payload + # has one source of truth: the Result. Callbacks ask `result.completed?`; + # trace consumers ask `result.trace_payload`. + class Result + attr_reader :status, :message, :duration_ms, :cost, + :input_tokens, :output_tokens, :error + + # status: :completed | :cancelled | :error | :failed + def initialize(status:, message:, duration_ms:, error: nil) + @status = status + @message = message + @duration_ms = duration_ms + @error = error + if message.respond_to?(:cost) + @cost = message.cost + @input_tokens = message.input_tokens + @output_tokens = message.output_tokens + end + end + + def completed? = @status == :completed + def cancelled? = @status == :cancelled + def errored? = @status == :error + def failed? = @status == :failed + + # The trace-event-shaped payload. Status as String to keep dashboard + # query compatibility with pre-refactor traces. tool_count optional. + def trace_payload(tool_count: nil) + payload = { + status: @status.to_s, + duration_ms: @duration_ms, + cost: @cost, + input_tokens: @input_tokens, + output_tokens: @output_tokens + } + if @error + payload[:error] = @error.class.name + payload[:error_message] = @error.message.to_s.truncate(200) + end + payload[:tool_count] = tool_count if tool_count + payload.compact + end + end + + def initialize( + message:, + subject:, + query_text:, + client:, + session_for:, + observer_factory:, + system_context:, + agent_name:, + tracer:, + on_finalized: ->(_msg, _ex) { }, + on_turn_finished: ->(_result) { }, + on_activity_tick: ->(_subject) { }, + empty_stream_retry_delay: DEFAULT_EMPTY_STREAM_RETRY_DELAY, + final_exchange_timeout: DEFAULT_FINAL_EXCHANGE_TIMEOUT, + final_exchange_retry_delay: DEFAULT_FINAL_EXCHANGE_RETRY_DELAY, + error_fallback_content: ERROR_FALLBACK_CONTENT, + error_feature: "opencode.turn" + ) + @message = message + @subject = subject + @query_text = query_text + @client = client + @session_for = session_for + @observer_factory = observer_factory + @system_context = system_context + @agent_name = agent_name + @tracer = tracer + @on_finalized = on_finalized + @on_turn_finished = on_turn_finished + @on_activity_tick = on_activity_tick + @empty_stream_retry_delay = empty_stream_retry_delay + @final_exchange_timeout = final_exchange_timeout + @final_exchange_retry_delay = final_exchange_retry_delay + @error_fallback_content = error_fallback_content + @error_feature = error_feature + @pre_turn_message_count = 0 + end + + def call + @turn_started_at = monotonic_now + emit("response.started", subject_id: @subject.id, message_id: @message.id) + + attempted_recreate = false + begin + run_turn + rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError + raise if attempted_recreate + @session_for.recreate!(@client) + # Distinguish the recovery-with-resend path: if our original + # async send was already accepted upstream, the recreate means + # the upstream may now have orphan work it's still spending on. + # The on-call engineer needs this distinction at 3am. + emit("session.recreated_with_resend", + session_id: @subject.opencode_session_id, subject_id: @subject.id) + attempted_recreate = true + retry + end + rescue StandardError => e + handle_unexpected_error(e) + end + + private + + # ---- Pipeline ------------------------------------------------------- + + def run_turn + session_id = @session_for.ensure!(@client) + emit_session_created_if_new + validate_session!(session_id) + + @client.send_message_async( + session_id, @query_text, + agent: @agent_name.call(@subject), + system: @system_context.call(@subject) + ) + + stream_result = stream_response(session_id) + exchange = fetch_current_exchange(session_id) + stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange) + last_assistant = exchange.reverse_each.detect { |m| m.dig(:info, :role) == "assistant" } + + @message.reload + if @message.cancelled? + save_cancelled_response(stream_result, last_assistant) + elsif stream_result[:full_text].blank? + recover_empty_stream(session_id, last_assistant, exchange) + else + finalize_response(stream_result, last_assistant, exchange) + end + end + + def validate_session!(session_id) + messages = @client.get_messages(session_id) + @pre_turn_message_count = messages.is_a?(Array) ? messages.size : 0 + end + + # `session.created` is emitted iff the session was *just* created by + # the call to `ensure!` above. We ask the Session — which did the + # work and knows the answer — instead of the subject's AR dirty + # tracking, which would couple Turn to ActiveRecord-shaped records. + def emit_session_created_if_new + return unless @session_for.respond_to?(:just_created?) + return unless @session_for.just_created? + emit("session.created", session_id: @subject.opencode_session_id, subject_id: @subject.id) + end + + # ---- Streaming ------------------------------------------------------ + + def stream_response(session_id) + reply = Opencode::Reply.new + @reply = reply + @observer_factory.call(@message).watch(reply) + + stream_started_at = monotonic_now + last_activity_touch_at = stream_started_at + first_token_at = nil + event_count = 0 + + begin + release_active_record_connections + # Throttled activity tick — fires on EVERY event including heartbeats + # (via the stream_events :on_activity_tick kwarg). We need heartbeats + # to count so that a user taking 30+ minutes to answer an ask-user + # prompt keeps the container warm: the agent itself emits no events + # while suspended, only the server's keep-alive does. + # + # The 5-minute throttle bounds DB write rate (one + # update_column per tick, not per heartbeat). Reaper safety + # is independent: the reaper's 30-minute idle threshold gives + # 6× headroom over this throttle, so even if several ticks + # miss the container survives. + # + # Wrapped in rescue so a transient DB blip on touch_activity + # is observable but doesn't kill an otherwise-healthy in-flight + # stream (heartbeats are advisory; next tick retries). + activity_tick = ->(_event) { + if (monotonic_now - last_activity_touch_at) >= ACTIVITY_TOUCH_INTERVAL + begin + @on_activity_tick.call(@subject) + last_activity_touch_at = monotonic_now + rescue StandardError => e + Opencode::ErrorReporter.report(e, handled: true, severity: :warning, + context: { feature: @error_feature, hook: :on_activity_tick }) + end + end + } + + @client.stream_events( + session_id: session_id, + reply: reply, + on_activity_tick: activity_tick + ) do |event| + event_count += 1 + reply.apply(event) + first_token_at ||= monotonic_now if reply.first_text_seen? + end + + emit("stream.completed", + duration_ms: elapsed_ms(stream_started_at), + first_token_ms: first_token_at && ((first_token_at - stream_started_at) * 1000).round, + event_count: event_count, + tool_count: reply.respond_to?(:tool_count) ? reply.tool_count : nil) + rescue Opencode::SessionNotFoundError + raise + rescue StandardError => e + Opencode::ErrorReporter.report(e, handled: true, severity: :warning, + context: { feature: @error_feature, error_class: e.class.name }) + emit("stream.interrupted", + duration_ms: elapsed_ms(stream_started_at), + event_count: event_count, + error: e.class.name, + error_message: e.message.to_s.truncate(200)) + attempt_stream_recovery(session_id, reply) + end + + reply.result + end + + def release_active_record_connections + return unless defined?(ActiveRecord::Base) + + ActiveRecord::Base.connection_handler.clear_active_connections! + end + + # If the session API is still reachable, fetch the current exchange + # and rebaseline `reply` to whatever the server has. If the API is + # also unreachable, keep whatever the reply accumulated before the + # interruption. + def attempt_stream_recovery(session_id, reply) + exchange = fetch_current_exchange(session_id) + last_msg = exchange.reverse_each.detect { |m| m.dig(:info, :role) == "assistant" } + return unless last_msg + + recovered_parts = Opencode::ResponseParser.extract_interleaved_parts(last_msg) + reply.replace_parts(recovered_parts) if recovered_parts.any? + rescue StandardError + # Session API also unreachable; keep whatever the reply has. + end + + # ---- Finalize ------------------------------------------------------- + + def finalize_response(stream_result, last_assistant, exchange) + result = authoritative_result(stream_result, exchange) + attrs = { + content: result[:full_text], + tool_calls_json: result[:tool_parts], + parts_json: result[:parts_json], + status: :completed + } + attrs[:reasoning] = result[:reasoning_text] if result[:reasoning_text].present? + attrs.merge!(extract_cost(last_assistant)) if last_assistant + + unless @message.finalize!(**attrs) + # finalize! returns false if message was cancelled/errored mid-flight. + emit_turn_finished(status: :cancelled) + return + end + + # Callbacks run AFTER the system of record is durable. If a callback + # raises (Redis flake on Turbo broadcast, ActiveJob enqueue hiccup + # on title generation), the turn is still completed; the failure + # is reported and isolated. Without this isolation a successful + # turn could be flipped to :error by an unrelated infra hiccup. + safe_callback(:on_finalized) { @on_finalized.call(@message, exchange) } + emit_turn_finished(status: :completed) + end + + def authoritative_result(stream_result, exchange) + exchange_result = current_turn_result(exchange) + return stream_result unless exchange_result + return stream_result if exchange_result[:full_text].blank? + + merge_stream_only_parts(stream_result, exchange_result) + end + + # The final session poll is authoritative for answer text and terminal + # tool payloads, but OpenCode emits some events (`todo.updated`, and + # whatever future bus events join Opencode::PartSource::STREAM_ONLY) + # that never persist as message parts. Preserve those synthetic + # stream parts across finalization so the refresh-rendered UI does + # not drop the live state the user watched stream in. + def merge_stream_only_parts(stream_result, exchange_result) + stream_parts = Array(stream_result[:parts_json]) + return exchange_result unless stream_parts.any? { |part| Opencode::PartSource.stream_only?(part) } + + exchange_parts = Array(exchange_result[:parts_json]).dup + merged = [] + + stream_parts.each do |part| + if Opencode::PartSource.stream_only?(part) + merged << part + elsif exchange_parts.any? + merged << exchange_parts.shift + end + end + + merged.concat(exchange_parts) + Opencode::Reply.distill(merged) + end + + def wait_for_final_exchange_result(session_id, stream_result, exchange) + result = authoritative_result(stream_result, exchange) + sync_reply_from_result(result) + return [ result, exchange ] if terminal_exchange_result?(result, exchange) + return [ result, exchange ] unless exchange_indicates_more_work?(exchange) + + emit("response.waiting_for_final_text", subject_id: @subject.id, message_id: @message.id) + deadline = monotonic_now + @final_exchange_timeout + + loop do + return [ result, exchange ] if monotonic_now >= deadline + + sleep @final_exchange_retry_delay if @final_exchange_retry_delay.positive? + exchange = fetch_current_exchange(session_id) + result = authoritative_result(stream_result, exchange) + sync_reply_from_result(result) + return [ result, exchange ] if terminal_exchange_result?(result, exchange) + return [ result, exchange ] unless exchange_indicates_more_work?(exchange) + end + end + + def sync_reply_from_result(result) + return unless @reply.respond_to?(:sync_recovered_parts) + return if result[:parts_json].blank? + + @reply.sync_recovered_parts(result[:parts_json]) + end + + def terminal_exchange_result?(result, exchange) + return false if result[:full_text].blank? + + last_assistant = current_turn_assistant_messages(exchange).last + return true unless last_assistant + return false if assistant_in_progress?(last_assistant) + + assistant_finish(last_assistant) != "tool-calls" + end + + def exchange_indicates_more_work?(exchange) + last_assistant = current_turn_assistant_messages(exchange).last + return false unless last_assistant + + assistant_finish(last_assistant) == "tool-calls" || assistant_in_progress?(last_assistant) + end + + def assistant_finish(assistant_message) + assistant_message.dig(:info, :finish).to_s + end + + def assistant_in_progress?(assistant_message) + time = assistant_message.dig(:info, :time) + return false unless time.is_a?(Hash) + return false unless time.key?(:created) + + time[:completed].blank? + end + + def current_turn_result(exchange) + parts = current_turn_assistant_messages(exchange).flat_map do |assistant_message| + Opencode::ResponseParser.extract_interleaved_parts(assistant_message) + end + return nil if parts.empty? + + Opencode::Reply.distill(parts) + end + + def current_turn_assistant_messages(exchange) + Array(exchange).select { |message| message.dig(:info, :role) == "assistant" } + end + + def save_cancelled_response(stream_result, last_assistant) + content = stream_result[:full_text].presence || "Response was stopped." + attrs = { + content: content, + tool_calls_json: stream_result[:tool_parts], + parts_json: stream_result[:parts_json] + } + attrs[:reasoning] = stream_result[:reasoning_text] if stream_result[:reasoning_text].present? + attrs.merge!(extract_cost(last_assistant)) if last_assistant + @message.update!(**attrs) + emit_turn_finished(status: :cancelled) + end + + # ---- Empty-stream recovery ------------------------------------------ + + def recover_empty_stream(session_id, last_assistant, exchange) + recovered = recover_from_exchange(last_assistant) + + unless recovered + sleep @empty_stream_retry_delay if @empty_stream_retry_delay.positive? + exchange = fetch_current_exchange(session_id) + last_assistant = exchange.reverse_each.detect { |m| m.dig(:info, :role) == "assistant" } + recovered = recover_from_exchange(last_assistant) + end + + if recovered + emit("response.recovered_from_exchange") + finalize_response(recovered, last_assistant, exchange) + elsif detect_upstream_error(last_assistant) + mark_error(reason: "upstream_llm_error") + else + mark_error(reason: "empty_stream") + end + end + + def recover_from_exchange(assistant_message) + return nil unless assistant_message + parts_json = Opencode::ResponseParser.extract_interleaved_parts(assistant_message) + return nil if parts_json.empty? + result = Opencode::Reply.distill(parts_json) + return nil if result[:full_text].blank? + + result + end + + def detect_upstream_error(assistant_message) + return nil unless assistant_message + error = Opencode::ResponseParser.extract_error(assistant_message) + return nil unless error + + emit("response.upstream_error", + error_name: error[:name], + error_message: error[:message], + status_code: error[:status_code], + provider_url: error[:url]) + + Opencode::ErrorReporter.report( + Opencode::Error.new("Upstream LLM error: #{error[:name]} - #{error[:message]}"), + handled: true, + severity: :error, + context: { feature: @error_feature, **error } + ) + + error + end + + # ---- Error paths ---------------------------------------------------- + + # Both error paths transition the message to :error through the + # CAS-safe Message#error! contract — a concurrent cancel that already + # moved the row out of :pending wins, and the canceller's terminal + # state survives. emit_turn_finished re-reads the persisted state + # (Result.message is reloaded) so callbacks receive the actual + # current state, not the state we wished we wrote. + + def handle_unexpected_error(e) + Opencode::ErrorReporter.report(e, handled: true, severity: :error, + context: { feature: @error_feature, message_id: @message.id, error_class: e.class.name }) + @message.error!(@error_fallback_content) + emit_turn_finished(status: :failed, error: e) + end + + def mark_error(reason:) + emit("response.error", reason: reason, message_id: @message.id, subject_id: @subject.id) + @message.error!(@error_fallback_content) + emit_turn_finished(status: :error) + end + + # ---- Trace + callback helpers -------------------------------------- + + def emit(name, **payload) + @tracer.call(name, **payload) + end + + def emit_turn_finished(status:, error: nil) + @message.reload if @message.respond_to?(:reload) + result = Result.new( + status: status, + message: @message, + duration_ms: elapsed_ms(@turn_started_at), + error: error + ) + + safe_callback(:on_turn_finished) { @on_turn_finished.call(result) } + + tool_count = @message.respond_to?(:tool_calls_json) ? @message.tool_calls_json&.size.to_i : nil + emit("turn.finished", **result.trace_payload(tool_count: tool_count)) + end + + # Run a callback, report any exception, but keep the turn in its + # current durable state. Side-effect callbacks (broadcast, artifact + # collection, title enqueueing) are not allowed to overwrite + # :completed → :error after the message is already persisted. + def safe_callback(name) + yield + rescue StandardError => e + Opencode::ErrorReporter.report(e, handled: true, severity: :warning, + context: { feature: @error_feature, callback: name, message_id: @message.id, error_class: e.class.name }) + emit("callback.error", callback: name.to_s, error_class: e.class.name) + end + + # ---- Exchange + cost helpers --------------------------------------- + + def fetch_current_exchange(session_id) + messages = @client.get_messages(session_id) + return [] unless messages.is_a?(Array) && messages.any? + + search_start_idx = [ @pre_turn_message_count.to_i, messages.length ].min + last_user_idx = nil + (messages.length - 1).downto(search_start_idx) do |idx| + message = messages[idx] + if message.dig(:info, :role) == "user" && user_message_text(message) == @query_text.to_s + last_user_idx = idx + break + end + end + return [] unless last_user_idx + messages[(last_user_idx + 1)..] + rescue Opencode::Error => e + Opencode::ErrorReporter.report(e, handled: true, severity: :warning, + context: { feature: @error_feature, action: "fetch_current_exchange", session_id: session_id }) + [] + end + + def user_message_text(message) + Opencode::ResponseParser.extract_text(message).to_s + end + + def extract_cost(assistant_msg) + cost = Opencode::ResponseParser.extract_cost(assistant_msg) + cache = Opencode::ResponseParser.extract_cache_tokens(assistant_msg) + tokens = Opencode::ResponseParser.extract_tokens(assistant_msg) || {} + { + cost: cost, + input_tokens: tokens[:input], + output_tokens: tokens[:output], + cache_read_tokens: cache[:cache_read], + cache_write_tokens: cache[:cache_write] + }.compact + end + + # ---- Time helpers --------------------------------------------------- + + def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + def elapsed_ms(t) = ((monotonic_now - t) * 1000).round + end +end diff --git a/lib/opencode/uploaded_files_prompt.rb b/lib/opencode/uploaded_files_prompt.rb new file mode 100644 index 0000000..2eb80f7 --- /dev/null +++ b/lib/opencode/uploaded_files_prompt.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Opencode + # The prompt body to send to an OpenCode agent when the user attached + # files: the user's text plus an instruction block naming each file by + # its sandboxed filename so the agent can read it with the `read` tool. + # + # Two outputs, both explicit: + # + # text — the prompt body to pass to send_message_async + # sandbox_file_names — map of sandbox_name => original filename, + # used by ReplyStream to show the user a + # recognizable name when the agent reads the + # file back. + # + # Previously this work lived in `Opencode::SandboxFiles`, an ActiveSupport + # concern that mutated a hidden `@sandbox_file_names` instance variable on + # the including job. ReplyStream then read that ivar back through a + # closure. State across class boundaries via shared mutable ivars is the + # kind of Sandi-smelly action-at-a-distance that breaks the moment + # someone forgets the contract. This value object replaces that with two + # named return values. + # + # Side effect, unchanged from the concern: file bytes are copied from + # ActiveStorage into the per-user OpenCode sandbox directory so the + # agent can read them with the `read` tool. The copy is path-escape + # guarded (the cleanpath of the destination must start with the + # sandbox dir prefix, no symlink trickery). + class UploadedFilesPrompt + attr_reader :text, :sandbox_file_names + + def initialize(user_message:, sandbox_path:, sandbox_name_for:) + @user_message = user_message + @sandbox_path = sandbox_path + @sandbox_name_for = sandbox_name_for + @sandbox_file_names = {} + @text = build_text + end + + private + + def build_text + raw = @user_message.content.to_s + return raw unless @user_message.files.attached? + + file_instructions = @user_message.files.map do |file| + sandbox_file = copy_to_sandbox(file) + @sandbox_file_names[sandbox_file.sandbox_name] = file.filename.to_s + "#{file.filename} -> #{sandbox_file.sandbox_name} (#{file.content_type}, #{file.byte_size} bytes)" + end + + [ + raw, + "", + "The user uploaded #{file_instructions.size} file(s). Read each file thoroughly, then consult your reference materials and verify any legal claims before responding:", + *file_instructions + ].join("\n").strip + end + + def copy_to_sandbox(file) + FileUtils.mkdir_p(@sandbox_path) + + sandbox_name = @sandbox_name_for.call(file) + dest = File.join(@sandbox_path, sandbox_name) + + resolved = Pathname.new(dest).cleanpath.to_s + unless resolved.start_with?(@sandbox_path) + raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}" + end + + File.open(dest, "wb") { |f| f.write(file.download) } + Placement.new(sandbox_name, dest) + end + + # Tiny value pair returned by copy_to_sandbox: the canonical filename + # the agent should read by, and the on-disk path the file ended up at. + # Internal to UploadedFilesPrompt — the caller (UploadedFilesPrompt + # itself) only needs the sandbox_name to embed in the prompt text. + Placement = Struct.new(:sandbox_name, :path) do + def to_s + path + end + end + end +end diff --git a/opencode-rails.gemspec b/opencode-rails.gemspec new file mode 100644 index 0000000..f2cb5fe --- /dev/null +++ b/opencode-rails.gemspec @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "lib/opencode/rails_version" + +Gem::Specification.new do |spec| + spec.name = "opencode-rails" + spec.version = Opencode::RAILS_VERSION + spec.authors = ["Ajay Krishnan"] + spec.email = ["opencode-rails@ajay.to"] + + spec.summary = "Production-grade Rails integration for OpenCode." + spec.description = <<~DESC + Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle + (idempotent ensure!/recreate!/abort! with row-level locks), a Turn + orchestrator that drives the Reply state machine + handles + session-not-found recovery, an artifact pipeline backed by + ActiveStorage, sandbox seeding, and tool-display value objects for + Turbo Stream broadcasts. Drop into any Rails 7.1+ app that wants + production-grade OpenCode streaming without rolling your own + boilerplate. + DESC + spec.homepage = "https://github.com/ajaynomics/opencode-rails" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.2.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues" + + spec.files = Dir.glob("lib/**/*.rb") + + Dir.glob("examples/**/*.rb") + + %w[README.md LICENSE CHANGELOG.md opencode-rails.gemspec] + spec.require_paths = ["lib"] + + # The opencode-ruby gem provides the wire-level Client + Reply primitives + # this gem builds on. During alpha both gems evolve in lockstep — we pin + # exactly (= not ~>) so that consumers always pick the version this gem + # was tested against. Bump to alpha2 when the paired release ships. + spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha2" + + # Rails sub-libraries used at runtime. Depending on these individually + # (instead of the `rails` umbrella) avoids forcing host apps to load + # ActionMailer, ActionCable, ActionView, etc. just to use this gem. + spec.add_runtime_dependency "activerecord", ">= 7.1", "< 9.0" + spec.add_runtime_dependency "activestorage", ">= 7.1", "< 9.0" + spec.add_runtime_dependency "activesupport", ">= 7.1", "< 9.0" + + spec.add_development_dependency "minitest", "~> 5.20" + spec.add_development_dependency "rake", "~> 13.0" +end diff --git a/test/opencode/artifact_test.rb b/test/opencode/artifact_test.rb new file mode 100644 index 0000000..d883434 --- /dev/null +++ b/test/opencode/artifact_test.rb @@ -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: "

", + content_type: "text/html", + metadata: { trust: "host_rendered" } + ) + assert_equal "host_rendered", artifact.metadata[:trust] + end +end diff --git a/test/opencode/error_reporter_test.rb b/test/opencode/error_reporter_test.rb new file mode 100644 index 0000000..987d2f7 --- /dev/null +++ b/test/opencode/error_reporter_test.rb @@ -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 diff --git a/test/opencode/exchange_test.rb b/test/opencode/exchange_test.rb new file mode 100644 index 0000000..ec78b2d --- /dev/null +++ b/test/opencode/exchange_test.rb @@ -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 diff --git a/test/opencode/impostor_test.rb b/test/opencode/impostor_test.rb new file mode 100644 index 0000000..3562063 --- /dev/null +++ b/test/opencode/impostor_test.rb @@ -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 diff --git a/test/opencode/loading_test.rb b/test/opencode/loading_test.rb new file mode 100644 index 0000000..b048b6c --- /dev/null +++ b/test/opencode/loading_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "test_helper" + +# Smoke test: every constant the gem promises is defined and points at +# the right kind of object. If require "opencode-rails" loads cleanly, +# this passes. If the require chain drifts or a file fails to load, +# this catches it before downstream apps do. +class Opencode::LoadingTest < Minitest::Test + GEM_PROVIDED_CONSTANTS = %w[ + Session Turn Exchange Artifact Sandbox SandboxFile + Transform Impostor MessageArtifacts UploadedFilesPrompt + ToolDisplay ErrorReporter + ].freeze + + # Reply / Tracer / Client / etc. ship in opencode-ruby and are + # transitively required by opencode-rails' umbrella. Verify the + # require chain pulled them in. + TRANSITIVE_CONSTANTS_FROM_OPENCODE_RUBY = %w[ + Client Reply ReplyObserver Tracer Prompts + ResponseParser ToolPart PartSource Todo + Instrumentation Error + ].freeze + + def test_gem_provides_expected_constants + GEM_PROVIDED_CONSTANTS.each do |name| + assert Opencode.const_defined?(name), + "Expected Opencode::#{name} to be defined after `require \"opencode-rails\"`" + end + end + + def test_transitively_loads_opencode_ruby_constants + TRANSITIVE_CONSTANTS_FROM_OPENCODE_RUBY.each do |name| + assert Opencode.const_defined?(name), + "Expected Opencode::#{name} to be defined transitively via opencode-ruby" + end + end + + # We check via path match on both directory ("/opencode-rails/") and + # installed-gem name ("/opencode-rails-VERSION/") so the assertion is + # robust to either a sibling-repo dev setup or a bundle-resolved gem + # install. + GEM_PATH_PATTERN = ->(name) { %r{/#{Regexp.escape(name)}[-/]} } + + def test_session_constant_points_at_this_gem + location = Opencode::Session.instance_method(:initialize).source_location.first + assert_match GEM_PATH_PATTERN.call("opencode-rails"), location, + "Expected Opencode::Session to be loaded from opencode-rails, got: #{location}" + end + + def test_client_constant_points_at_opencode_ruby + location = Opencode::Client.instance_method(:initialize).source_location.first + assert_match GEM_PATH_PATTERN.call("opencode-ruby"), location, + "Expected Opencode::Client to come from opencode-ruby, got: #{location}" + refute_match GEM_PATH_PATTERN.call("opencode-rails"), location, + "Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)" + end + + def test_version_constant + assert_match(/\A\d+\.\d+\.\d+/, Opencode::RAILS_VERSION) + end + + def test_no_opencode_rails_module + # Defining Opencode::Rails as a module would shadow ::Rails for any + # host code that references top-level Rails.* from inside the + # Opencode:: namespace (e.g. lib/opencode/containers/container.rb). + # Verify the namespace stays clean — version lives at + # Opencode::RAILS_VERSION, not Opencode::Rails::VERSION. + refute Opencode.const_defined?(:Rails), + "Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace" + end +end diff --git a/test/opencode/message_artifacts_test.rb b/test/opencode/message_artifacts_test.rb new file mode 100644 index 0000000..ad8bd6c --- /dev/null +++ b/test/opencode/message_artifacts_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "test_helper" + +# Contract smoke for Opencode::MessageArtifacts (the idempotent +# ActiveStorage-backed artifact attachment pipeline). Behavioral +# coverage — including ActiveStorage attachment, Transform application, +# error reporting via Opencode::ErrorReporter — lives in the host app. +class Opencode::MessageArtifactsTest < Minitest::Test + def test_initialize_takes_message_feature_and_optional_transforms + params = Opencode::MessageArtifacts.instance_method(:initialize).parameters + by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) } + + assert_includes by_kind[:keyreq], :message, + "MessageArtifacts must require a message: keyword" + assert_includes by_kind[:keyreq], :feature, + "MessageArtifacts must require a feature: keyword (used in error reports)" + assert_includes by_kind[:key] || [], :transforms, + "MessageArtifacts must accept an optional transforms: keyword" + end + + def test_public_api_is_attach_from + assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false), + "MessageArtifacts's only public verb is #attach_from" + end +end diff --git a/test/opencode/sandbox_file_test.rb b/test/opencode/sandbox_file_test.rb new file mode 100644 index 0000000..78e26e9 --- /dev/null +++ b/test/opencode/sandbox_file_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "test_helper" +require "fileutils" +require "tmpdir" +require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for + +# Smoke test for Opencode::SandboxFile: instantiate against a real +# pathname, verify basename/size/content/content_type readers and the +# identity conversion to Opencode::Artifact via #as_artifact. +class Opencode::SandboxFileTest < Minitest::Test + def setup + @tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-") + @path = File.join(@tmpdir, "notes.md") + File.write(@path, "# hello\nworld\n") + # SandboxFile uses `start_with?` against this prefix to detect path + # escape; it expects a String with trailing separator so that + # /sandbox-1 doesn't false-positive on /sandbox-10/foo. + @sandbox_prefix = File.join(@tmpdir, "") + end + + def teardown + FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir) + end + + def test_basic_readers + file = Opencode::SandboxFile.new( + path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000 + ) + + assert_equal "notes.md", file.basename + assert file.size.positive? + assert_equal "# hello\nworld\n", file.content + assert file.safe?, "small text file inside sandbox should be safe" + end + + def test_content_type_detection_via_marcel + file = Opencode::SandboxFile.new( + path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000 + ) + # Marcel detects .md as text/markdown. + assert_match(/markdown|text/, file.content_type) + end + + def test_safe_rejects_files_over_size_cap + file = Opencode::SandboxFile.new( + path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 5 + ) + refute file.safe?, "file larger than max_bytes must be unsafe" + end + + def test_as_artifact_returns_opencode_artifact_value + file = Opencode::SandboxFile.new( + path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000 + ) + + artifact = file.as_artifact + assert_instance_of Opencode::Artifact, artifact + assert_equal "notes.md", artifact.filename + assert_equal "# hello\nworld\n", artifact.content + end +end diff --git a/test/opencode/sandbox_test.rb b/test/opencode/sandbox_test.rb new file mode 100644 index 0000000..151353e --- /dev/null +++ b/test/opencode/sandbox_test.rb @@ -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 diff --git a/test/opencode/session_test.rb b/test/opencode/session_test.rb new file mode 100644 index 0000000..0655de8 --- /dev/null +++ b/test/opencode/session_test.rb @@ -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 diff --git a/test/opencode/tool_display_test.rb b/test/opencode/tool_display_test.rb new file mode 100644 index 0000000..4ffdccf --- /dev/null +++ b/test/opencode/tool_display_test.rb @@ -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 diff --git a/test/opencode/transform_test.rb b/test/opencode/transform_test.rb new file mode 100644 index 0000000..00e91b8 --- /dev/null +++ b/test/opencode/transform_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" + +# Smoke test for Opencode::Transform — the base class for +# content-rewriting transforms. It is intentionally abstract: +# #source_filename, #destination_filename, and #render all raise +# NotImplementedError. Subclasses (host-side) provide the meat. +# These tests document the abstract contract. +class Opencode::TransformTest < Minitest::Test + def test_source_filename_is_abstract + err = assert_raises(NotImplementedError) { Opencode::Transform.new.source_filename } + assert_match(/must implement #source_filename/, err.message) + end + + def test_destination_filename_is_abstract + err = assert_raises(NotImplementedError) { Opencode::Transform.new.destination_filename } + assert_match(/must implement #destination_filename/, err.message) + end + + def test_render_is_abstract + err = assert_raises(NotImplementedError) { Opencode::Transform.new.render(Object.new) } + assert_match(/must implement #render/, err.message) + end + + def test_purge_impostors_defaults_to_false + refute Opencode::Transform.new.purge_impostors?, + "Default #purge_impostors? must be false — conservative opt-in by subclasses" + end + + # A trivial concrete subclass exercises the defaults that DO exist + # (#applies_to?, #trusted?, #owned_filenames all delegate to the + # two abstract filename methods). + class FakeTransform < Opencode::Transform + def source_filename = "agent-output.json" + def destination_filename = "rendered.html" + end + + Attachment = Struct.new(:filename, keyword_init: true) + Basenamed = Struct.new(:basename, keyword_init: true) + + def test_applies_to_matches_source_filename_by_default + transform = FakeTransform.new + matching = Basenamed.new(basename: "agent-output.json") + other = Basenamed.new(basename: "something-else.json") + + assert transform.applies_to?(matching) + refute transform.applies_to?(other) + end + + def test_trusted_matches_destination_filename_by_default + transform = FakeTransform.new + trusted = Attachment.new(filename: "rendered.html") + untrusted = Attachment.new(filename: "agent-output.json") + + assert transform.trusted?(trusted) + refute transform.trusted?(untrusted) + end + + def test_owned_filenames_is_source_and_destination + assert_equal %w[agent-output.json rendered.html], + FakeTransform.new.owned_filenames + end + + def test_error_is_a_subclass_of_standarderror + assert_operator Opencode::Transform::Error, :<, StandardError, + "Transform::Error must be rescuable by `rescue StandardError`" + end +end diff --git a/test/opencode/turn_test.rb b/test/opencode/turn_test.rb new file mode 100644 index 0000000..7e45da1 --- /dev/null +++ b/test/opencode/turn_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "test_helper" + +# Contract smoke for Opencode::Turn (the orchestrator) and its inner +# Result value object. Behavioral coverage (the full send -> stream -> +# recover -> finalize loop) lives in the host application — Turn needs +# an Opencode::Client, an AR Message, a subject record, etc., which are +# all integration-level concerns. +class Opencode::TurnTest < Minitest::Test + REQUIRED_INIT_KEYS = %i[ + message subject query_text client session_for observer_factory + system_context agent_name tracer + ].freeze + + OPTIONAL_INIT_KEYS = %i[ + on_finalized on_turn_finished on_activity_tick + empty_stream_retry_delay final_exchange_timeout + final_exchange_retry_delay error_fallback_content error_feature + ].freeze + + def test_required_keyword_arguments + params = Opencode::Turn.instance_method(:initialize).parameters + required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort + + assert_equal REQUIRED_INIT_KEYS.sort, required, + "Turn's required keyword args drifted. Expected: #{REQUIRED_INIT_KEYS.sort}, got: #{required}" + end + + def test_optional_keyword_arguments_match_documented_surface + params = Opencode::Turn.instance_method(:initialize).parameters + optional = params.select { |kind, _| kind == :key }.map(&:last).sort + + assert_equal OPTIONAL_INIT_KEYS.sort, optional, + "Turn's optional keyword args drifted. Expected: #{OPTIONAL_INIT_KEYS.sort}, got: #{optional}" + end + + def test_public_surface_is_call_only + # Turn is an orchestrator; the only public verb is #call. Everything + # else is internal. Locking this prevents helpers from accidentally + # bleeding into the public API. + assert_equal [ :call ], Opencode::Turn.instance_methods(false) + end + + def test_result_is_a_value_object_with_status_predicates + fake_message = Struct.new(:cost, :input_tokens, :output_tokens, keyword_init: true).new( + cost: 0.012, input_tokens: 100, output_tokens: 50 + ) + result = Opencode::Turn::Result.new( + status: :completed, message: fake_message, duration_ms: 1234 + ) + + assert result.completed? + refute result.cancelled? + refute result.errored? + refute result.failed? + assert_equal 1234, result.duration_ms + assert_equal 0.012, result.cost + assert_equal 100, result.input_tokens + assert_equal 50, result.output_tokens + end +end diff --git a/test/opencode/uploaded_files_prompt_test.rb b/test/opencode/uploaded_files_prompt_test.rb new file mode 100644 index 0000000..d6777c1 --- /dev/null +++ b/test/opencode/uploaded_files_prompt_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "test_helper" +require "fileutils" +require "tmpdir" + +# Smoke test for Opencode::UploadedFilesPrompt. The sandbox_path: +# inversion (per D14 in the design doc) means we can exercise the +# happy path against a tmpdir without needing Rails / AR / ActiveStorage +# fixtures. Behavioral tests covering ActiveStorage attached_blob +# enumeration live in the host application. +class Opencode::UploadedFilesPromptTest < Minitest::Test + # Minimal stub for a user message: #content (the raw user text) and + # #files (an ActiveStorage-like collection with #attached?). Real + # behavior is exercised in the host's test suite. + FakeMessage = Struct.new(:content, :files, keyword_init: true) + EmptyFiles = Struct.new(:attached) do + def attached? = attached + end + + def setup + @tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-") + end + + def teardown + FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir) + end + + def test_initialize_takes_three_required_keywords + params = Opencode::UploadedFilesPrompt.instance_method(:initialize).parameters + required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort + + assert_equal %i[sandbox_name_for sandbox_path user_message], required, + "UploadedFilesPrompt requires user_message:, sandbox_path:, sandbox_name_for:" + end + + def test_text_returns_raw_content_when_no_files_attached + message = FakeMessage.new(content: "hello world", files: EmptyFiles.new(false)) + prompt = Opencode::UploadedFilesPrompt.new( + user_message: message, + sandbox_path: @tmpdir, + sandbox_name_for: ->(file) { file.filename.to_s } + ) + + assert_equal "hello world", prompt.text, + "No attached files => text is the raw user content" + assert_equal({}, prompt.sandbox_file_names, + "No attached files => sandbox_file_names map stays empty") + end + + def test_public_surface + assert_equal %i[sandbox_file_names text].sort, + Opencode::UploadedFilesPrompt.instance_methods(false).sort + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..3c769f0 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "opencode-rails" +require "minitest/autorun" -- 2.49.1