Compare commits
1 Commits
v0.0.1.alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0c4cd3cd |
39
.github/workflows/test.yml
vendored
Normal file
39
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
ruby: ["3.2", "3.3", "3.4"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Ruby ${{ matrix.ruby }}
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: ${{ matrix.ruby }}
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bundle exec rake test
|
||||||
|
|
||||||
|
- name: Build gem
|
||||||
|
run: gem build opencode-rails.gemspec
|
||||||
|
|
||||||
|
- name: Verify gem loads after install
|
||||||
|
# opencode-rails depends on opencode-ruby; until both gems
|
||||||
|
# are on rubygems.org, the install step here will only resolve
|
||||||
|
# if opencode-ruby has been pre-installed or is reachable.
|
||||||
|
# When the gems do publish, the runtime_dependency on
|
||||||
|
# opencode-ruby will Just Work via rubygems.
|
||||||
|
run: |
|
||||||
|
gem install --local opencode-rails-*.gem --conservative
|
||||||
|
ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION'
|
||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,8 +1,24 @@
|
|||||||
# Changelog
|
# 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
|
## 0.0.1.alpha1 — 2026-05-20
|
||||||
|
|
||||||
Initial public alpha. Extracted from a production multi-product Rails app (`ajent-rails`) where these objects shipped under `lib/opencode/rails/` before being carved out into a standalone gem.
|
Initial public alpha. Extracted from a production Rails app where these objects shipped as in-tree library code before being carved out into a standalone gem.
|
||||||
|
|
||||||
**Includes:**
|
**Includes:**
|
||||||
|
|
||||||
@@ -29,6 +45,6 @@ Initial public alpha. Extracted from a production multi-product Rails app (`ajen
|
|||||||
**Known limitations (alpha):**
|
**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.
|
- Apply-patch tool's post-write file content is not extracted (wire-format limitation in OpenCode v1.15+); affected files surface via the `opencode.apply_patch.artifacts_dropped` instrumentation event. Future work: optional sandbox-read fallback path.
|
||||||
- Smoke tests only inside the gem (14 tests). Behavioral coverage lives in the host app that originally produced this code (`ajent-rails`'s `test/lib/opencode/rails/`). Standalone gem-side test suite using Combustion is open work.
|
- Smoke tests only inside the gem. Behavioral coverage currently lives in the host app that produced this code. A standalone gem-side test suite using Combustion is open work.
|
||||||
- No generator (`rails g opencode:install`) yet.
|
- No generator (`rails g opencode:install`) yet.
|
||||||
- No Rails Engine integration — `require "opencode-rails"` is sufficient.
|
- No Rails Engine integration — `require "opencode-rails"` is sufficient.
|
||||||
|
|||||||
73
CONTRIBUTING.md
Normal file
73
CONTRIBUTING.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Contributing to opencode-rails
|
||||||
|
|
||||||
|
## Running the test suite
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bundle install
|
||||||
|
bundle exec rake test
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke tests live in `test/opencode/`. They prove that:
|
||||||
|
|
||||||
|
- Every gem-provided constant resolves
|
||||||
|
- The opencode-ruby umbrella loads transitively
|
||||||
|
- Source locations point at the right gem
|
||||||
|
- The version constant is not under an `Opencode::Rails` module
|
||||||
|
(that would shadow `::Rails` in host apps; see comment in
|
||||||
|
`lib/opencode/rails_version.rb`)
|
||||||
|
- Public API contracts on the AR-coupled classes hold (Session, Turn,
|
||||||
|
MessageArtifacts) — verified via `Method#parameters`, not behavior
|
||||||
|
- Value objects (Artifact, SandboxFile, Transform, Impostor) round-trip
|
||||||
|
through their public interfaces
|
||||||
|
|
||||||
|
Behavioral tests for AR + ActiveStorage paths live in the host app
|
||||||
|
that produced this code. Same pattern as opencode-ruby — gem-side
|
||||||
|
smokes prove load correctness; host-side tests prove integration
|
||||||
|
correctness.
|
||||||
|
|
||||||
|
## Working on opencode-rails together with opencode-ruby
|
||||||
|
|
||||||
|
opencode-rails depends on opencode-ruby. During development of either
|
||||||
|
gem you frequently need changes in opencode-ruby to be picked up by
|
||||||
|
opencode-rails without going through a release cycle.
|
||||||
|
|
||||||
|
**Use Bundler's `local` config — not Gemfile conditionals.** Bundler
|
||||||
|
behavior must never depend on filesystem state inside the Gemfile.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Once per dev machine. Replace the path with wherever you have
|
||||||
|
# opencode-ruby checked out.
|
||||||
|
bundle config local.opencode-ruby /path/to/opencode-ruby
|
||||||
|
|
||||||
|
# Then bundle install/update against the local copy:
|
||||||
|
bundle install
|
||||||
|
```
|
||||||
|
|
||||||
|
To switch back to the released version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bundle config --delete local.opencode-ruby
|
||||||
|
bundle install
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Bundler's documentation on local git overrides](https://bundler.io/v2.5/git.html#local).
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
This gem is in alpha. Versions ship as `0.0.x.alphaN` until the public
|
||||||
|
API stabilizes.
|
||||||
|
|
||||||
|
Coordinated releases with opencode-ruby:
|
||||||
|
|
||||||
|
1. In opencode-ruby: bump `Opencode::VERSION`, tag, push.
|
||||||
|
2. In opencode-rails: bump `Opencode::RAILS_VERSION`, update the
|
||||||
|
`add_runtime_dependency "opencode-ruby", "= X.Y.Z"` line in the
|
||||||
|
gemspec to match the new opencode-ruby version (alpha discipline:
|
||||||
|
pin exactly, not pessimistically). Tag, push.
|
||||||
|
3. In any consumer app: bump both `tag:` lines (or version pins) in
|
||||||
|
the Gemfile to the new versions; `bundle update opencode-ruby
|
||||||
|
opencode-rails`.
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
File at <https://github.com/ajaynomics/opencode-rails/issues>.
|
||||||
8
Gemfile
8
Gemfile
@@ -1,11 +1,3 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# opencode-ruby is the underlying wire client. During alpha both gems
|
|
||||||
# evolve in lockstep; the gemspec pins a release version, but for local
|
|
||||||
# dev we pull from the sibling working copy so changes in opencode-ruby
|
|
||||||
# are picked up without a release cycle.
|
|
||||||
if File.exist?(File.expand_path("../opencode-ruby", __dir__))
|
|
||||||
gem "opencode-ruby", path: File.expand_path("../opencode-ruby", __dir__)
|
|
||||||
end
|
|
||||||
|
|
||||||
gemspec
|
gemspec
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# opencode-rails
|
# opencode-rails
|
||||||
|
|
||||||
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://gitea.krishnan.ca/ajaynomics/opencode-ruby).
|
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://github.com/ajaynomics/opencode-ruby).
|
||||||
|
|
||||||
> **Alpha software.** API will change before 1.0. Pin to a specific version.
|
> **Alpha software.** API will change before 1.0. Pin to a specific version.
|
||||||
|
|
||||||
|
|||||||
215
examples/rails_integration.rb
Normal file
215
examples/rails_integration.rb
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# examples/rails_integration.rb
|
||||||
|
#
|
||||||
|
# Production-shaped integration of opencode-rails into a Rails app.
|
||||||
|
# This file is NOT loaded by the gem at runtime — it's a reference
|
||||||
|
# blueprint. Drop the patterns into your app and adapt to your
|
||||||
|
# domain (Conversation/Message/User naming, ActiveStorage attachments,
|
||||||
|
# Turbo broadcasts).
|
||||||
|
#
|
||||||
|
# The pattern is extracted from a production Rails app where it ships
|
||||||
|
# multiple OpenCode-backed conversational products. It works for any
|
||||||
|
# host that has:
|
||||||
|
#
|
||||||
|
# - A "conversation" AR row that owns an `opencode_session_id:string`
|
||||||
|
# column and a has_many :messages association.
|
||||||
|
# - A "message" AR row with `role:string, status:string,
|
||||||
|
# parts_json:jsonb, content:text` (plus whatever fields your domain needs).
|
||||||
|
# - SolidQueue (or Sidekiq, GoodJob) for the background job.
|
||||||
|
# - Turbo for live streaming UX (optional but assumed).
|
||||||
|
#
|
||||||
|
# What each section demonstrates:
|
||||||
|
#
|
||||||
|
# 1. Initializer: route Instrumentation + ErrorReporter adapters
|
||||||
|
# to ActiveSupport::Notifications and Rails.error.report.
|
||||||
|
# 2. Job: orchestrate one turn with Opencode::Session + Opencode::Turn.
|
||||||
|
# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts.
|
||||||
|
# 4. Permissions builder: per-product session permission rules.
|
||||||
|
#
|
||||||
|
# Tested patterns. Every line below has been exercised in production.
|
||||||
|
# Copy what you need; adapt to your domain.
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 1. config/initializers/opencode.rb
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wire the two adapters opencode-ruby + opencode-rails ship with. The
|
||||||
|
# gems are silent by default; the host explicitly opts into routing
|
||||||
|
# events to its observability stack.
|
||||||
|
|
||||||
|
Rails.application.config.to_prepare do
|
||||||
|
# opencode-ruby: every HTTP request, SSE event lifecycle, recovery
|
||||||
|
# path flows through this adapter as an ActiveSupport::Notifications
|
||||||
|
# event. Subscribers in this app pick it up via
|
||||||
|
# `ActiveSupport::Notifications.subscribe("opencode.*", ...)`.
|
||||||
|
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
||||||
|
ActiveSupport::Notifications.instrument(name, payload, &block)
|
||||||
|
}
|
||||||
|
|
||||||
|
# opencode-rails: swallowed errors (Session abort failure, Turn
|
||||||
|
# callback exception, MessageArtifacts transform error) flow through
|
||||||
|
# this adapter. Wire your Honeybadger / Sentry / Rails.error reporter
|
||||||
|
# here.
|
||||||
|
Opencode::ErrorReporter.adapter = ->(error, **opts) {
|
||||||
|
Rails.error.report(error, **opts)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 2. app/jobs/generate_response_job.rb
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# One job per assistant message. Idempotent on the message row: if
|
||||||
|
# the message is already :completed or :error, the job is a no-op.
|
||||||
|
# The Turn class handles all the orchestration; this job is mostly
|
||||||
|
# wiring + error fallback.
|
||||||
|
|
||||||
|
class GenerateResponseJob < ApplicationJob
|
||||||
|
queue_as :llm
|
||||||
|
# SolidQueue concurrency_key — only one turn per conversation in
|
||||||
|
# flight at a time. Without this, a user sending two messages back-
|
||||||
|
# to-back can race two turns through the same OpenCode session.
|
||||||
|
limits_concurrency to: 1, key: ->(message) { "GenerateResponseJob/#{message.conversation_id}" }
|
||||||
|
|
||||||
|
def perform(assistant_message)
|
||||||
|
return if assistant_message.terminal? # idempotent
|
||||||
|
|
||||||
|
conversation = assistant_message.conversation
|
||||||
|
user_message = conversation.messages.where(role: :user).order(:created_at).last
|
||||||
|
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
|
||||||
|
|
||||||
|
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
|
||||||
|
# OpenCode session if conversation.opencode_session_id is blank;
|
||||||
|
# returns the existing id otherwise.
|
||||||
|
session = Opencode::Session.new(
|
||||||
|
conversation,
|
||||||
|
permissions_for: ->(record) { permission_rules_for(record) },
|
||||||
|
on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) }
|
||||||
|
)
|
||||||
|
|
||||||
|
# Turn: the orchestrator. Drives send -> stream -> recover ->
|
||||||
|
# finalize. Pass it the host's ReplyObserver factory so the gem's
|
||||||
|
# Reply state machine can bridge to your Turbo broadcasts.
|
||||||
|
Opencode::Turn.new(
|
||||||
|
message: assistant_message,
|
||||||
|
subject: conversation,
|
||||||
|
query_text: user_message.content,
|
||||||
|
client: client,
|
||||||
|
session_for: ->(*) { session },
|
||||||
|
observer_factory: ->(message) { ReplyStream.new(message: message) },
|
||||||
|
system_context: build_system_context(conversation),
|
||||||
|
agent_name: "default",
|
||||||
|
tracer: Opencode::Tracer.new(prefix: "opencode."),
|
||||||
|
on_turn_finished: ->(result) {
|
||||||
|
Rails.logger.info("turn finished status=#{result.status} cost=#{result.cost}")
|
||||||
|
}
|
||||||
|
).call
|
||||||
|
rescue StandardError => e
|
||||||
|
Opencode::ErrorReporter.report(e, severity: :error,
|
||||||
|
context: { message_id: assistant_message.id, conversation_id: conversation.id })
|
||||||
|
assistant_message.update!(status: :error, content: "Sorry, something went wrong.")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def permission_rules_for(conversation)
|
||||||
|
# Per-product permissions. The shape mirrors what
|
||||||
|
# opencode-ruby's Client#create_session expects in `permissions:`.
|
||||||
|
[
|
||||||
|
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" },
|
||||||
|
{ type: "edit", action: "deny", path: "*" } # default deny outside sandbox
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_system_context(conversation)
|
||||||
|
# System prompt context the agent gets. Your app probably already
|
||||||
|
# has helpers for this; the gem doesn't impose a shape.
|
||||||
|
{
|
||||||
|
user_name: conversation.user.name,
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
sandbox_path: "/sandbox/#{conversation.id}"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 3. app/services/reply_stream.rb
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# An Opencode::ReplyObserver implementation that bridges the gem's
|
||||||
|
# state-machine callbacks (part_appended, part_updated, finalized) to
|
||||||
|
# Turbo Stream broadcasts. The gem ships the protocol; the host owns
|
||||||
|
# the rendering.
|
||||||
|
#
|
||||||
|
# This is one of three places hosts customize: the renderer of a
|
||||||
|
# tool-call part. The other two are permission_rules_for and
|
||||||
|
# build_system_context above.
|
||||||
|
|
||||||
|
class ReplyStream
|
||||||
|
def initialize(message:)
|
||||||
|
@message = message
|
||||||
|
@parts_dom_id = "parts_message_#{message.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Called every time a new part shows up in the reply.
|
||||||
|
def on_part_appended(part)
|
||||||
|
Turbo::StreamsChannel.broadcast_append_to(
|
||||||
|
@message.conversation,
|
||||||
|
target: @parts_dom_id,
|
||||||
|
partial: "messages/part",
|
||||||
|
locals: { part: part, message: @message }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Called when an existing part's content grows (text/reasoning
|
||||||
|
# deltas, tool state changes).
|
||||||
|
def on_part_updated(part, _index)
|
||||||
|
Turbo::StreamsChannel.broadcast_update_to(
|
||||||
|
@message.conversation,
|
||||||
|
target: "part_#{part['id']}_message_#{@message.id}",
|
||||||
|
partial: "messages/part",
|
||||||
|
locals: { part: part, message: @message }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Called when the turn finalizes. Use this to swap "Thinking…"
|
||||||
|
# placeholders, update message status indicators, etc.
|
||||||
|
def on_finalized(reply_result)
|
||||||
|
Turbo::StreamsChannel.broadcast_update_to(
|
||||||
|
@message.conversation,
|
||||||
|
target: "message_#{@message.id}_status",
|
||||||
|
partial: "messages/status",
|
||||||
|
locals: { message: @message, result: reply_result }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Optional: called when the gem catches an exception mid-stream and
|
||||||
|
# has done its own recovery (recreated session, retried, etc.). Use
|
||||||
|
# this for a brief transient banner in the UI.
|
||||||
|
def on_error(message, severity:)
|
||||||
|
Turbo::StreamsChannel.broadcast_replace_to(
|
||||||
|
@message.conversation,
|
||||||
|
target: "message_#{@message.id}_status",
|
||||||
|
html: %(<div class="banner banner--warn">#{message}</div>)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# That's it. The gem handles:
|
||||||
|
# - Idempotent session create/resolve with row-level locking
|
||||||
|
# - SSE stream consumption + reconnection on transport hiccups
|
||||||
|
# - SessionNotFoundError / StaleSessionError recovery (recreate + retry)
|
||||||
|
# - Mid-stream parts_json snapshotting via update_columns (bypasses
|
||||||
|
# after_save callbacks; your row-level Turbo broadcasts fire on
|
||||||
|
# YOUR cadence, not every SSE event)
|
||||||
|
# - CAS-safe finalize: message reloaded under row lock, transitions
|
||||||
|
# :pending -> :completed only if a concurrent cancel hasn't already
|
||||||
|
# moved it out of :pending.
|
||||||
|
# - Cost + token extraction from the final exchange
|
||||||
|
# - Artifact pipeline (MessageArtifacts.attach_from) with optional
|
||||||
|
# transforms (host-rendered HTML, JSON-to-PDF, etc.)
|
||||||
|
#
|
||||||
|
# Your job is the wiring. About 80 lines of Ruby gets you a production-
|
||||||
|
# grade chat agent.
|
||||||
@@ -17,7 +17,7 @@ require "active_support/core_ext/string/inflections" # demodulize, underscore, c
|
|||||||
require "active_support/core_ext/string/filters" # squish, truncate
|
require "active_support/core_ext/string/filters" # squish, truncate
|
||||||
require "active_support/core_ext/numeric/time" # 2.seconds, 5.minutes, etc.
|
require "active_support/core_ext/numeric/time" # 2.seconds, 5.minutes, etc.
|
||||||
|
|
||||||
require_relative "opencode/rails/version"
|
require_relative "opencode/rails_version"
|
||||||
require_relative "opencode/error_reporter"
|
require_relative "opencode/error_reporter"
|
||||||
|
|
||||||
# Tier 4 leaves (no deps on other rails-gem files)
|
# Tier 4 leaves (no deps on other rails-gem files)
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ module Opencode
|
|||||||
# - Opencode::Exchange.tool_artifacts — content lives inside a tool
|
# - Opencode::Exchange.tool_artifacts — content lives inside a tool
|
||||||
# call's input/metadata (write tool).
|
# call's input/metadata (write tool).
|
||||||
# - Opencode::SandboxFile#as_artifact — identity conversion of a
|
# - Opencode::SandboxFile#as_artifact — identity conversion of a
|
||||||
# sandbox-resident file (the default path for Blackline + Raven).
|
# sandbox-resident file (the default identity path).
|
||||||
#
|
#
|
||||||
# Transforms also return Artifacts; that's why FlightResultsTransform
|
# Transforms also return Artifacts — e.g. a host-rendered HTML
|
||||||
# returns one with the host-rendered HTML + trust metadata stamp.
|
# artifact carrying a trust-metadata stamp.
|
||||||
#
|
#
|
||||||
# An Artifact knows how to attach itself to a message, idempotently:
|
# An Artifact knows how to attach itself to a message, idempotently:
|
||||||
# it consults `message.artifacts` to skip if its filename is already
|
# it consults `message.artifacts` to skip if its filename is already
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ module Opencode
|
|||||||
eligible = file_entries.reject { |e| e[:type] == "delete" }
|
eligible = file_entries.reject { |e| e[:type] == "delete" }
|
||||||
next if eligible.empty?
|
next if eligible.empty?
|
||||||
|
|
||||||
Opencode::Instrumentation.instrument("opencode.apply_patch.artifacts_dropped",
|
Opencode::Instrumentation.notify("opencode.apply_patch.artifacts_dropped",
|
||||||
file_count: eligible.size,
|
file_count: eligible.size,
|
||||||
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
|
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
|
||||||
message_id: part[:messageID],
|
message_id: part[:messageID],
|
||||||
session_id: part[:sessionID],
|
session_id: part[:sessionID],
|
||||||
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
|
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
|
||||||
"extraction requires sandbox-read which is not yet wired into ResponseParser") { }
|
"extraction requires sandbox-read which is not yet wired into ResponseParser")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ module Opencode
|
|||||||
# 1. A previous job retry attached the destination filename via the
|
# 1. A previous job retry attached the destination filename via the
|
||||||
# tool-extracted path (the agent wrote a file with that name and
|
# tool-extracted path (the agent wrote a file with that name and
|
||||||
# it landed before the trusted render did).
|
# it landed before the trusted render did).
|
||||||
# 2. A pre-substrate code path persisted an agent-authored HTML file
|
# 2. A pre-substrate code path persisted an agent-authored file
|
||||||
# with the destination filename (the historical AIGL exploit
|
# under the destination filename — the same-name stored-XSS
|
||||||
# surface that motivated the trust boundary in the first place).
|
# attack the trust boundary exists to prevent.
|
||||||
# 3. A previous transform version stamped different metadata and the
|
# 3. A previous transform version stamped different metadata and the
|
||||||
# trust check now correctly rejects it.
|
# trust check now correctly rejects it.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ module Opencode
|
|||||||
#
|
#
|
||||||
# Two-line usage:
|
# Two-line usage:
|
||||||
#
|
#
|
||||||
# Opencode::MessageArtifacts.new(message: m, feature: "blackline", transforms: [])
|
# Opencode::MessageArtifacts.new(message: m, feature: "chat", transforms: [])
|
||||||
# .attach_from(exchange: exchange, sandbox: sandbox)
|
# .attach_from(exchange: exchange, sandbox: sandbox)
|
||||||
#
|
#
|
||||||
# All four phases (tool extract, transform routing, impostor purge,
|
# All four phases (tool extract, transform routing, impostor purge,
|
||||||
@@ -24,15 +24,14 @@ module Opencode
|
|||||||
MAX_SANDBOX_ARTIFACTS = 20
|
MAX_SANDBOX_ARTIFACTS = 20
|
||||||
|
|
||||||
# default_attach values:
|
# default_attach values:
|
||||||
# :all — Blackline/Raven default. Every safe sandbox file that
|
# :all — every safe sandbox file that no transform claims falls
|
||||||
# no transform claims falls through to identity attach.
|
# through to identity attach. Use when the agent's `write`
|
||||||
# The agent's `write` outputs are final document bytes the
|
# outputs are final document bytes the host serves back
|
||||||
# host serves back unchanged.
|
# unchanged.
|
||||||
# :none — AIGL. The agent's sandbox is full of internal working
|
# :none — only transform-claimed files attach; everything else stays
|
||||||
# scratch (notes.md, map.md, timeline.md) plus the one
|
# agent-internal. Use when the agent's sandbox is full of
|
||||||
# file the transform claims (flight-results.json). Only
|
# working scratch the user shouldn't see, and only specific
|
||||||
# transform-claimed files attach; everything else stays
|
# filenames (claimed by transforms) become artifacts.
|
||||||
# agent-internal.
|
|
||||||
def initialize(message:, feature:, transforms: [], default_attach: :all,
|
def initialize(message:, feature:, transforms: [], default_attach: :all,
|
||||||
max_sandbox_files: MAX_SANDBOX_ARTIFACTS)
|
max_sandbox_files: MAX_SANDBOX_ARTIFACTS)
|
||||||
@message = message
|
@message = message
|
||||||
@@ -82,11 +81,10 @@ module Opencode
|
|||||||
if (transform = transforms.find { |t| t.applies_to?(file) })
|
if (transform = transforms.find { |t| t.applies_to?(file) })
|
||||||
attached += 1 if apply_transform(transform, file)
|
attached += 1 if apply_transform(transform, file)
|
||||||
elsif default_attach == :all
|
elsif default_attach == :all
|
||||||
# Default identity path. Blackline/Raven default — every safe
|
# Default identity path: every safe sandbox file that no
|
||||||
# sandbox file that no transform claims attaches as-is. AIGL
|
# transform claims attaches as-is. Callers that want the
|
||||||
# passes default_attach: :none so non-transform files (the
|
# opposite (only transform-claimed files attach) construct
|
||||||
# agent's notes.md / map.md / timeline.md scratch) don't
|
# MessageArtifacts with default_attach: :none.
|
||||||
# auto-attach.
|
|
||||||
attached += 1 if file.as_artifact.attach_to(message)
|
attached += 1 if file.as_artifact.attach_to(message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
# We can't reuse the same constant from a second gem, so we use a
|
# We can't reuse the same constant from a second gem, so we use a
|
||||||
# distinct, non-namespaced constant.
|
# distinct, non-namespaced constant.
|
||||||
module Opencode
|
module Opencode
|
||||||
RAILS_VERSION = "0.0.1.alpha1"
|
RAILS_VERSION = "0.0.1.alpha2"
|
||||||
end
|
end
|
||||||
@@ -32,9 +32,9 @@ module Opencode
|
|||||||
|
|
||||||
# Yields SandboxFile values for every file in the sandbox that
|
# Yields SandboxFile values for every file in the sandbox that
|
||||||
# passes its own #safe? predicate AND was modified after the cutoff.
|
# passes its own #safe? predicate AND was modified after the cutoff.
|
||||||
# When `after:` is nil (callers without a user_message handle, e.g.
|
# When `after:` is nil (callers without a user_message handle —
|
||||||
# AIGL on certain finalize paths), no mtime filter is applied —
|
# e.g. finalize paths that scan the whole sandbox), no mtime filter
|
||||||
# only safety + filetype.
|
# is applied — only safety + filetype.
|
||||||
def files(after: nil)
|
def files(after: nil)
|
||||||
return enum_for(:files, after: after) unless block_given?
|
return enum_for(:files, after: after) unless block_given?
|
||||||
return unless exists?
|
return unless exists?
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ module Opencode
|
|||||||
|
|
||||||
# Identity conversion: this sandbox file → an Artifact carrying the
|
# Identity conversion: this sandbox file → an Artifact carrying the
|
||||||
# file's own bytes. Used by the substrate's default (non-transform)
|
# file's own bytes. Used by the substrate's default (non-transform)
|
||||||
# path for Blackline + Raven, whose agents write document bytes
|
# path, where the agent writes document bytes directly to the
|
||||||
# directly to the sandbox and expect them attached unchanged.
|
# sandbox and the host serves them back unchanged.
|
||||||
def as_artifact
|
def as_artifact
|
||||||
Artifact.new(
|
Artifact.new(
|
||||||
filename: basename,
|
filename: basename,
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
module Opencode
|
module Opencode
|
||||||
# Owns the lifecycle of an OpenCode session against a domain record.
|
# Owns the lifecycle of an OpenCode session against a domain record.
|
||||||
#
|
#
|
||||||
# Three near-identical implementations of this lifecycle existed on
|
# Consolidates a lifecycle that's easy to get subtly wrong: a host
|
||||||
# Blackline::Conversation, Raven::Conversation, and AIGL::Trip. Each
|
# with N conversational products tends to grow N near-identical
|
||||||
# had subtle differences (Blackline and Raven didn't take a row-level
|
# session-management code paths that drift on the details (which
|
||||||
# lock; AIGL did). Sandi Metz flagged the shotgun surgery in the
|
# one took a row-level lock? which one swallowed teardown errors?).
|
||||||
# architectural review — a change to the lifecycle had to be made in
|
# Single PORO, one place to change, no shotgun surgery when the
|
||||||
# three places that looked alike but disagreed on locking. This PORO
|
# protocol evolves.
|
||||||
# is the consolidated role.
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
#
|
#
|
||||||
@@ -23,11 +22,10 @@ module Opencode
|
|||||||
# service.abort!(client) # best-effort upstream abort
|
# service.abort!(client) # best-effort upstream abort
|
||||||
#
|
#
|
||||||
# The permissions_for: callable receives the record at mint! time and
|
# The permissions_for: callable receives the record at mint! time and
|
||||||
# returns the permissions array for client.create_session. Product-
|
# returns the permissions array for client.create_session. Per-product
|
||||||
# specific scoping (e.g. AIGL's workspace_key/trip_id branching) lives
|
# 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
|
# in the caller's lambda, not in this class — that keeps Session free
|
||||||
# of any reference to permission-building helpers and preserves the
|
# of any reference to permission-building helpers.
|
||||||
# rails-tier -> containers-tier boundary the design doc locks in.
|
|
||||||
#
|
#
|
||||||
# The on_error: callable is invoked when abort! catches an
|
# The on_error: callable is invoked when abort! catches an
|
||||||
# Opencode::Error during teardown. Callers wire their own observability
|
# Opencode::Error during teardown. Callers wire their own observability
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ module Opencode
|
|||||||
# icon identifier.
|
# icon identifier.
|
||||||
#
|
#
|
||||||
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
|
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
|
||||||
# so Blackline views, AIGL views, and any future OpenCode-backed
|
# so any view that renders OpenCode tool calls — across whatever
|
||||||
# feature can render tool calls consistently.
|
# products the host runs — can do so consistently.
|
||||||
#
|
#
|
||||||
# ## Data shape (Opencode::Reply writes this into `parts_json`)
|
# ## Data shape (Opencode::Reply writes this into `parts_json`)
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ module Opencode
|
|||||||
# agent wrote" and "bytes the host signs and attaches."
|
# agent wrote" and "bytes the host signs and attaches."
|
||||||
#
|
#
|
||||||
# The default substrate path is identity: any sandbox file the
|
# The default substrate path is identity: any sandbox file the
|
||||||
# allowlist accepts gets attached as-is. Blackline and Raven use
|
# allowlist accepts gets attached as-is. This works when the agent
|
||||||
# the default — their agents `write` final document bytes the host
|
# writes the final document bytes itself and the host just serves
|
||||||
# serves back unchanged. AIGL's contract is structurally different:
|
# them back unchanged.
|
||||||
# the agent writes JSON, the **host** must render that JSON into
|
#
|
||||||
# trusted HTML before attaching, because the resulting HTML gets
|
# Subclass Transform when the contract is structurally different:
|
||||||
# served inline from the app origin and an agent-written filename
|
# the agent writes raw data (e.g. JSON), and the **host** must
|
||||||
# can't be permitted as stored-XSS.
|
# 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
|
# Subclass hooks (override these — none have a generic default
|
||||||
# that's safe to inherit):
|
# that's safe to inherit):
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ module Opencode
|
|||||||
# smallest honest shape.
|
# smallest honest shape.
|
||||||
#
|
#
|
||||||
# Composition over inheritance: every product-specific concern is a
|
# Composition over inheritance: every product-specific concern is a
|
||||||
# collaborator passed in. Turn never sees Blackline, Raven, or AIGL by
|
# collaborator passed in. Turn never sees any specific product by
|
||||||
# name.
|
# name — the orchestration shape is uniform.
|
||||||
#
|
#
|
||||||
# Collaborators
|
# Collaborators
|
||||||
# -------------
|
# -------------
|
||||||
@@ -50,7 +50,7 @@ module Opencode
|
|||||||
#
|
#
|
||||||
# observer_factory callable: ->(message) returning an observer that
|
# observer_factory callable: ->(message) returning an observer that
|
||||||
# responds to #watch(reply). Concretely:
|
# responds to #watch(reply). Concretely:
|
||||||
# ->(message) { Blackline::ReplyStream.new(...) }.
|
# ->(message) { MyApp::ReplyStream.new(message: message) }.
|
||||||
#
|
#
|
||||||
# system_context callable: ->(subject) -> String system prompt.
|
# system_context callable: ->(subject) -> String system prompt.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require_relative "lib/opencode/rails/version"
|
require_relative "lib/opencode/rails_version"
|
||||||
|
|
||||||
Gem::Specification.new do |spec|
|
Gem::Specification.new do |spec|
|
||||||
spec.name = "opencode-rails"
|
spec.name = "opencode-rails"
|
||||||
spec.version = Opencode::RAILS_VERSION
|
spec.version = Opencode::RAILS_VERSION
|
||||||
spec.authors = ["Ajay Krishnan"]
|
spec.authors = ["Ajay Krishnan"]
|
||||||
spec.email = ["ajay@krishnan.ca"]
|
spec.email = ["opencode-rails@ajay.to"]
|
||||||
|
|
||||||
spec.summary = "Production-grade Rails integration for OpenCode."
|
spec.summary = "Production-grade Rails integration for OpenCode."
|
||||||
spec.description = <<~DESC
|
spec.description = <<~DESC
|
||||||
@@ -19,22 +19,25 @@ Gem::Specification.new do |spec|
|
|||||||
production-grade OpenCode streaming without rolling your own
|
production-grade OpenCode streaming without rolling your own
|
||||||
boilerplate.
|
boilerplate.
|
||||||
DESC
|
DESC
|
||||||
spec.homepage = "https://gitea.krishnan.ca/ajaynomics/opencode-rails"
|
spec.homepage = "https://github.com/ajaynomics/opencode-rails"
|
||||||
spec.license = "MIT"
|
spec.license = "MIT"
|
||||||
spec.required_ruby_version = ">= 3.2.0"
|
spec.required_ruby_version = ">= 3.2.0"
|
||||||
|
|
||||||
|
spec.metadata["homepage_uri"] = spec.homepage
|
||||||
spec.metadata["source_code_uri"] = spec.homepage
|
spec.metadata["source_code_uri"] = spec.homepage
|
||||||
spec.metadata["changelog_uri"] = "#{spec.homepage}/src/branch/main/CHANGELOG.md"
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
||||||
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
||||||
|
|
||||||
spec.files = Dir.glob("lib/**/*.rb") +
|
spec.files = Dir.glob("lib/**/*.rb") +
|
||||||
|
Dir.glob("examples/**/*.rb") +
|
||||||
%w[README.md LICENSE CHANGELOG.md opencode-rails.gemspec]
|
%w[README.md LICENSE CHANGELOG.md opencode-rails.gemspec]
|
||||||
spec.require_paths = ["lib"]
|
spec.require_paths = ["lib"]
|
||||||
|
|
||||||
# The opencode-ruby gem provides the wire-level Client + Reply primitives
|
# The opencode-ruby gem provides the wire-level Client + Reply primitives
|
||||||
# this gem builds on. Versions are kept in lockstep during the alpha
|
# this gem builds on. During alpha both gems evolve in lockstep — we pin
|
||||||
# phase; will relax to a looser pessimistic pin once both gems stabilize.
|
# exactly (= not ~>) so that consumers always pick the version this gem
|
||||||
spec.add_runtime_dependency "opencode-ruby", "~> 0.0.1.alpha1"
|
# 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
|
# Rails sub-libraries used at runtime. Depending on these individually
|
||||||
# (instead of the `rails` umbrella) avoids forcing host apps to load
|
# (instead of the `rails` umbrella) avoids forcing host apps to load
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ require "test_helper"
|
|||||||
|
|
||||||
# Smoke test for Opencode::Artifact — verifies the value-object surface
|
# Smoke test for Opencode::Artifact — verifies the value-object surface
|
||||||
# (filename/content/content_type readers). Behavioral tests around how
|
# (filename/content/content_type readers). Behavioral tests around how
|
||||||
# the host's ActiveStorage-backed AIGL::Trip/etc. records build artifact
|
# the host's ActiveStorage-backed records build artifact collections
|
||||||
# collections live in the host's test suite.
|
# live in the host's test suite.
|
||||||
class Opencode::ArtifactTest < Minitest::Test
|
class Opencode::ArtifactTest < Minitest::Test
|
||||||
def test_value_object_readers
|
def test_value_object_readers
|
||||||
artifact = Opencode::Artifact.new(
|
artifact = Opencode::Artifact.new(
|
||||||
|
|||||||
@@ -47,4 +47,29 @@ class Opencode::ErrorReporterTest < Minitest::Test
|
|||||||
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
|
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
|
||||||
assert invoked, "Adapter should be invoked even with no kwargs"
|
assert invoked, "Adapter should be invoked even with no kwargs"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
26
test/opencode/impostor_test.rb
Normal file
26
test/opencode/impostor_test.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::Impostor. Wraps an ActiveStorage
|
||||||
|
# attachment that's been replaced by a Transform-rendered artifact.
|
||||||
|
# Behavioral tests against real ActiveStorage::Attachment live in
|
||||||
|
# the host application.
|
||||||
|
class Opencode::ImpostorTest < Minitest::Test
|
||||||
|
def test_initialize_takes_attachment_keyword
|
||||||
|
params = Opencode::Impostor.instance_method(:initialize).parameters
|
||||||
|
assert_includes params, [ :keyreq, :attachment ],
|
||||||
|
"Impostor must require an attachment: keyword (ActiveStorage::Attachment-like)"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_api
|
||||||
|
assert_equal %i[filename purge!].sort,
|
||||||
|
Opencode::Impostor.instance_methods(false).sort
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_filename_delegates_to_attachment
|
||||||
|
attachment_double = Struct.new(:filename).new("legacy.html")
|
||||||
|
impostor = Opencode::Impostor.new(attachment: attachment_double)
|
||||||
|
assert_equal "legacy.html", impostor.filename
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -36,16 +36,24 @@ class Opencode::LoadingTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
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
|
def test_session_constant_points_at_this_gem
|
||||||
location = Opencode::Session.instance_method(:initialize).source_location.first
|
location = Opencode::Session.instance_method(:initialize).source_location.first
|
||||||
assert_match %r{/opencode-rails/}, location,
|
assert_match GEM_PATH_PATTERN.call("opencode-rails"), location,
|
||||||
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
|
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_client_constant_points_at_opencode_ruby
|
def test_client_constant_points_at_opencode_ruby
|
||||||
location = Opencode::Client.instance_method(:initialize).source_location.first
|
location = Opencode::Client.instance_method(:initialize).source_location.first
|
||||||
assert_match %r{/opencode-ruby/}, location,
|
assert_match GEM_PATH_PATTERN.call("opencode-ruby"), location,
|
||||||
"Expected Opencode::Client to come from opencode-ruby, got: #{location}"
|
"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
|
end
|
||||||
|
|
||||||
def test_version_constant
|
def test_version_constant
|
||||||
|
|||||||
26
test/opencode/message_artifacts_test.rb
Normal file
26
test/opencode/message_artifacts_test.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::MessageArtifacts (the idempotent
|
||||||
|
# ActiveStorage-backed artifact attachment pipeline). Behavioral
|
||||||
|
# coverage — including ActiveStorage attachment, Transform application,
|
||||||
|
# error reporting via Opencode::ErrorReporter — lives in the host app.
|
||||||
|
class Opencode::MessageArtifactsTest < Minitest::Test
|
||||||
|
def test_initialize_takes_message_feature_and_optional_transforms
|
||||||
|
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
|
||||||
|
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
|
||||||
|
|
||||||
|
assert_includes by_kind[:keyreq], :message,
|
||||||
|
"MessageArtifacts must require a message: keyword"
|
||||||
|
assert_includes by_kind[:keyreq], :feature,
|
||||||
|
"MessageArtifacts must require a feature: keyword (used in error reports)"
|
||||||
|
assert_includes by_kind[:key] || [], :transforms,
|
||||||
|
"MessageArtifacts must accept an optional transforms: keyword"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_api_is_attach_from
|
||||||
|
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
|
||||||
|
"MessageArtifacts's only public verb is #attach_from"
|
||||||
|
end
|
||||||
|
end
|
||||||
62
test/opencode/sandbox_file_test.rb
Normal file
62
test/opencode/sandbox_file_test.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "fileutils"
|
||||||
|
require "tmpdir"
|
||||||
|
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
||||||
|
|
||||||
|
# Smoke test for Opencode::SandboxFile: instantiate against a real
|
||||||
|
# pathname, verify basename/size/content/content_type readers and the
|
||||||
|
# identity conversion to Opencode::Artifact via #as_artifact.
|
||||||
|
class Opencode::SandboxFileTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
|
||||||
|
@path = File.join(@tmpdir, "notes.md")
|
||||||
|
File.write(@path, "# hello\nworld\n")
|
||||||
|
# SandboxFile uses `start_with?` against this prefix to detect path
|
||||||
|
# escape; it expects a String with trailing separator so that
|
||||||
|
# /sandbox-1 doesn't false-positive on /sandbox-10/foo.
|
||||||
|
@sandbox_prefix = File.join(@tmpdir, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_basic_readers
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "notes.md", file.basename
|
||||||
|
assert file.size.positive?
|
||||||
|
assert_equal "# hello\nworld\n", file.content
|
||||||
|
assert file.safe?, "small text file inside sandbox should be safe"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_content_type_detection_via_marcel
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||||
|
)
|
||||||
|
# Marcel detects .md as text/markdown.
|
||||||
|
assert_match(/markdown|text/, file.content_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_safe_rejects_files_over_size_cap
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 5
|
||||||
|
)
|
||||||
|
refute file.safe?, "file larger than max_bytes must be unsafe"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_as_artifact_returns_opencode_artifact_value
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = file.as_artifact
|
||||||
|
assert_instance_of Opencode::Artifact, artifact
|
||||||
|
assert_equal "notes.md", artifact.filename
|
||||||
|
assert_equal "# hello\nworld\n", artifact.content
|
||||||
|
end
|
||||||
|
end
|
||||||
53
test/opencode/sandbox_test.rb
Normal file
53
test/opencode/sandbox_test.rb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "fileutils"
|
||||||
|
require "tmpdir"
|
||||||
|
require "marcel" # SandboxFile (yielded by Sandbox#files) needs marcel
|
||||||
|
|
||||||
|
# Smoke test for Opencode::Sandbox: instantiate against a real tmpdir,
|
||||||
|
# verify #path, #exists?, and that #files / #file return empty / nil
|
||||||
|
# when no sandbox files are present. Behavioral coverage of actual file
|
||||||
|
# enumeration lives in the host application where real per-product
|
||||||
|
# sandbox configurations exercise the path.
|
||||||
|
class Opencode::SandboxTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-test-")
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_path_and_exists_when_directory_present
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
assert_equal @tmpdir, sandbox.path
|
||||||
|
assert sandbox.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_exists_false_when_path_missing
|
||||||
|
sandbox = Opencode::Sandbox.new(path: File.join(@tmpdir, "missing"))
|
||||||
|
refute sandbox.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_files_returns_enumerator_yielding_nothing_when_empty
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
# No block given => Enumerator.
|
||||||
|
assert_kind_of Enumerator, sandbox.files
|
||||||
|
assert_equal [], sandbox.files.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_files_yields_sandbox_files_for_real_entries
|
||||||
|
File.write(File.join(@tmpdir, "notes.md"), "x")
|
||||||
|
File.write(File.join(@tmpdir, "map.md"), "y")
|
||||||
|
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
basenames = sandbox.files.map(&:basename).sort
|
||||||
|
assert_equal %w[map.md notes.md], basenames
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_file_returns_nil_for_missing_relative_name
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
assert_nil sandbox.file("nope.txt")
|
||||||
|
end
|
||||||
|
end
|
||||||
27
test/opencode/session_test.rb
Normal file
27
test/opencode/session_test.rb
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::Session. Behavioral coverage (idempotent
|
||||||
|
# ensure!/recreate!/abort! with row-level locking, race-safety,
|
||||||
|
# permissions_for callable handoff) lives in the host application
|
||||||
|
# where AR fixtures + a real ActiveRecord row exist.
|
||||||
|
class Opencode::SessionTest < Minitest::Test
|
||||||
|
def test_initialize_takes_record_and_two_keyword_callables
|
||||||
|
params = Opencode::Session.instance_method(:initialize).parameters
|
||||||
|
|
||||||
|
assert_includes params, [ :req, :record ],
|
||||||
|
"Session must take a positional record (an AR row with #with_lock, #title, etc.)"
|
||||||
|
assert_includes params, [ :keyreq, :permissions_for ],
|
||||||
|
"Session must require a permissions_for: callable (host-injected per-product permissions)"
|
||||||
|
assert_includes params, [ :key, :on_error ],
|
||||||
|
"Session must accept an optional on_error: callable for adapter-style error reporting"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_api_is_ensure_recreate_abort_just_created
|
||||||
|
methods = Opencode::Session.instance_methods(false).sort
|
||||||
|
assert_equal %i[abort! ensure! just_created? recreate!].sort, methods.sort,
|
||||||
|
"Session's public surface should be exactly: ensure!/recreate!/abort!/just_created?. " \
|
||||||
|
"Found: #{methods.inspect}"
|
||||||
|
end
|
||||||
|
end
|
||||||
55
test/opencode/tool_display_test.rb
Normal file
55
test/opencode/tool_display_test.rb
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Smoke tests for Opencode::ToolDisplay — the view-model that converts
|
||||||
|
# raw tool-part hashes into Turbo-Stream-friendly props. We exercise
|
||||||
|
# the predicate surface for the canonical 'read' tool plus the
|
||||||
|
# unknown-tool fallback. Exhaustive per-tool render tests live in the
|
||||||
|
# host where the renderer templates are exercised.
|
||||||
|
class Opencode::ToolDisplayTest < Minitest::Test
|
||||||
|
def test_known_read_tool_canonicalization
|
||||||
|
display = Opencode::ToolDisplay.new(
|
||||||
|
"type" => "tool", "tool" => "read", "status" => "completed",
|
||||||
|
"input" => { "filePath" => "/sandbox/notes.md" }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "read", display.canonical_tool
|
||||||
|
assert display.known?
|
||||||
|
assert display.completed?
|
||||||
|
assert display.terminal?
|
||||||
|
refute display.errored?
|
||||||
|
refute display.in_flight?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_running_status
|
||||||
|
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "read", "status" => "running")
|
||||||
|
assert display.in_flight?
|
||||||
|
refute display.terminal?
|
||||||
|
refute display.completed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_errored_status
|
||||||
|
display = Opencode::ToolDisplay.new(
|
||||||
|
"type" => "tool", "tool" => "edit", "status" => "error", "error" => "permission denied"
|
||||||
|
)
|
||||||
|
assert display.errored?
|
||||||
|
assert display.terminal?
|
||||||
|
refute display.completed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unknown_tool_falls_back_gracefully
|
||||||
|
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "wat", "status" => "completed")
|
||||||
|
refute display.known?,
|
||||||
|
"Unknown tools must not claim to be known — host renderer dispatches a fallback view"
|
||||||
|
refute_nil display.canonical_tool,
|
||||||
|
"Unknown tools still need a canonical_tool so DOM ids stay stable"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_nil_part_initializes_safely
|
||||||
|
# ToolDisplay tolerates a nil part because callers sometimes pass
|
||||||
|
# message.parts_json entries that aren't tool parts.
|
||||||
|
display = Opencode::ToolDisplay.new(nil)
|
||||||
|
refute display.known?
|
||||||
|
end
|
||||||
|
end
|
||||||
69
test/opencode/transform_test.rb
Normal file
69
test/opencode/transform_test.rb
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Smoke test for Opencode::Transform — the base class for
|
||||||
|
# content-rewriting transforms. It is intentionally abstract:
|
||||||
|
# #source_filename, #destination_filename, and #render all raise
|
||||||
|
# NotImplementedError. Subclasses (host-side) provide the meat.
|
||||||
|
# These tests document the abstract contract.
|
||||||
|
class Opencode::TransformTest < Minitest::Test
|
||||||
|
def test_source_filename_is_abstract
|
||||||
|
err = assert_raises(NotImplementedError) { Opencode::Transform.new.source_filename }
|
||||||
|
assert_match(/must implement #source_filename/, err.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_destination_filename_is_abstract
|
||||||
|
err = assert_raises(NotImplementedError) { Opencode::Transform.new.destination_filename }
|
||||||
|
assert_match(/must implement #destination_filename/, err.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_render_is_abstract
|
||||||
|
err = assert_raises(NotImplementedError) { Opencode::Transform.new.render(Object.new) }
|
||||||
|
assert_match(/must implement #render/, err.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_purge_impostors_defaults_to_false
|
||||||
|
refute Opencode::Transform.new.purge_impostors?,
|
||||||
|
"Default #purge_impostors? must be false — conservative opt-in by subclasses"
|
||||||
|
end
|
||||||
|
|
||||||
|
# A trivial concrete subclass exercises the defaults that DO exist
|
||||||
|
# (#applies_to?, #trusted?, #owned_filenames all delegate to the
|
||||||
|
# two abstract filename methods).
|
||||||
|
class FakeTransform < Opencode::Transform
|
||||||
|
def source_filename = "agent-output.json"
|
||||||
|
def destination_filename = "rendered.html"
|
||||||
|
end
|
||||||
|
|
||||||
|
Attachment = Struct.new(:filename, keyword_init: true)
|
||||||
|
Basenamed = Struct.new(:basename, keyword_init: true)
|
||||||
|
|
||||||
|
def test_applies_to_matches_source_filename_by_default
|
||||||
|
transform = FakeTransform.new
|
||||||
|
matching = Basenamed.new(basename: "agent-output.json")
|
||||||
|
other = Basenamed.new(basename: "something-else.json")
|
||||||
|
|
||||||
|
assert transform.applies_to?(matching)
|
||||||
|
refute transform.applies_to?(other)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_trusted_matches_destination_filename_by_default
|
||||||
|
transform = FakeTransform.new
|
||||||
|
trusted = Attachment.new(filename: "rendered.html")
|
||||||
|
untrusted = Attachment.new(filename: "agent-output.json")
|
||||||
|
|
||||||
|
assert transform.trusted?(trusted)
|
||||||
|
refute transform.trusted?(untrusted)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_owned_filenames_is_source_and_destination
|
||||||
|
assert_equal %w[agent-output.json rendered.html],
|
||||||
|
FakeTransform.new.owned_filenames
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_error_is_a_subclass_of_standarderror
|
||||||
|
assert_operator Opencode::Transform::Error, :<, StandardError,
|
||||||
|
"Transform::Error must be rescuable by `rescue StandardError`"
|
||||||
|
end
|
||||||
|
end
|
||||||
62
test/opencode/turn_test.rb
Normal file
62
test/opencode/turn_test.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
|
||||||
|
# Result value object. Behavioral coverage (the full send -> stream ->
|
||||||
|
# recover -> finalize loop) lives in the host application — Turn needs
|
||||||
|
# an Opencode::Client, an AR Message, a subject record, etc., which are
|
||||||
|
# all integration-level concerns.
|
||||||
|
class Opencode::TurnTest < Minitest::Test
|
||||||
|
REQUIRED_INIT_KEYS = %i[
|
||||||
|
message subject query_text client session_for observer_factory
|
||||||
|
system_context agent_name tracer
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
OPTIONAL_INIT_KEYS = %i[
|
||||||
|
on_finalized on_turn_finished on_activity_tick
|
||||||
|
empty_stream_retry_delay final_exchange_timeout
|
||||||
|
final_exchange_retry_delay error_fallback_content error_feature
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def test_required_keyword_arguments
|
||||||
|
params = Opencode::Turn.instance_method(:initialize).parameters
|
||||||
|
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
|
||||||
|
|
||||||
|
assert_equal REQUIRED_INIT_KEYS.sort, required,
|
||||||
|
"Turn's required keyword args drifted. Expected: #{REQUIRED_INIT_KEYS.sort}, got: #{required}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_optional_keyword_arguments_match_documented_surface
|
||||||
|
params = Opencode::Turn.instance_method(:initialize).parameters
|
||||||
|
optional = params.select { |kind, _| kind == :key }.map(&:last).sort
|
||||||
|
|
||||||
|
assert_equal OPTIONAL_INIT_KEYS.sort, optional,
|
||||||
|
"Turn's optional keyword args drifted. Expected: #{OPTIONAL_INIT_KEYS.sort}, got: #{optional}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_surface_is_call_only
|
||||||
|
# Turn is an orchestrator; the only public verb is #call. Everything
|
||||||
|
# else is internal. Locking this prevents helpers from accidentally
|
||||||
|
# bleeding into the public API.
|
||||||
|
assert_equal [ :call ], Opencode::Turn.instance_methods(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_result_is_a_value_object_with_status_predicates
|
||||||
|
fake_message = Struct.new(:cost, :input_tokens, :output_tokens, keyword_init: true).new(
|
||||||
|
cost: 0.012, input_tokens: 100, output_tokens: 50
|
||||||
|
)
|
||||||
|
result = Opencode::Turn::Result.new(
|
||||||
|
status: :completed, message: fake_message, duration_ms: 1234
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.completed?
|
||||||
|
refute result.cancelled?
|
||||||
|
refute result.errored?
|
||||||
|
refute result.failed?
|
||||||
|
assert_equal 1234, result.duration_ms
|
||||||
|
assert_equal 0.012, result.cost
|
||||||
|
assert_equal 100, result.input_tokens
|
||||||
|
assert_equal 50, result.output_tokens
|
||||||
|
end
|
||||||
|
end
|
||||||
55
test/opencode/uploaded_files_prompt_test.rb
Normal file
55
test/opencode/uploaded_files_prompt_test.rb
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "fileutils"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
# Smoke test for Opencode::UploadedFilesPrompt. The sandbox_path:
|
||||||
|
# inversion (per D14 in the design doc) means we can exercise the
|
||||||
|
# happy path against a tmpdir without needing Rails / AR / ActiveStorage
|
||||||
|
# fixtures. Behavioral tests covering ActiveStorage attached_blob
|
||||||
|
# enumeration live in the host application.
|
||||||
|
class Opencode::UploadedFilesPromptTest < Minitest::Test
|
||||||
|
# Minimal stub for a user message: #content (the raw user text) and
|
||||||
|
# #files (an ActiveStorage-like collection with #attached?). Real
|
||||||
|
# behavior is exercised in the host's test suite.
|
||||||
|
FakeMessage = Struct.new(:content, :files, keyword_init: true)
|
||||||
|
EmptyFiles = Struct.new(:attached) do
|
||||||
|
def attached? = attached
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup
|
||||||
|
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_takes_three_required_keywords
|
||||||
|
params = Opencode::UploadedFilesPrompt.instance_method(:initialize).parameters
|
||||||
|
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
|
||||||
|
|
||||||
|
assert_equal %i[sandbox_name_for sandbox_path user_message], required,
|
||||||
|
"UploadedFilesPrompt requires user_message:, sandbox_path:, sandbox_name_for:"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_text_returns_raw_content_when_no_files_attached
|
||||||
|
message = FakeMessage.new(content: "hello world", files: EmptyFiles.new(false))
|
||||||
|
prompt = Opencode::UploadedFilesPrompt.new(
|
||||||
|
user_message: message,
|
||||||
|
sandbox_path: @tmpdir,
|
||||||
|
sandbox_name_for: ->(file) { file.filename.to_s }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "hello world", prompt.text,
|
||||||
|
"No attached files => text is the raw user content"
|
||||||
|
assert_equal({}, prompt.sandbox_file_names,
|
||||||
|
"No attached files => sandbox_file_names map stays empty")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_surface
|
||||||
|
assert_equal %i[sandbox_file_names text].sort,
|
||||||
|
Opencode::UploadedFilesPrompt.instance_methods(false).sort
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user