Compare commits
6 Commits
v0.0.1.alp
...
v0.0.1.alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ccfe0f7cf | |||
| 65ee701cae | |||
| 0611a2aa82 | |||
| 69cfff55b2 | |||
| 57701a1701 | |||
| 49f1ac7b9d |
39
.github/workflows/test.yml
vendored
39
.github/workflows/test.yml
vendored
@@ -1,39 +0,0 @@
|
||||
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,24 +1,8 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
**Includes:**
|
||||
|
||||
@@ -45,6 +29,6 @@ Initial public alpha. Extracted from a production Rails app where these objects
|
||||
**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.
|
||||
- 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.
|
||||
- No generator (`rails g opencode:install`) yet.
|
||||
- No Rails Engine integration — `require "opencode-rails"` is sufficient.
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# 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,3 +1,11 @@
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# opencode-rails
|
||||
|
||||
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://github.com/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://gitea.krishnan.ca/ajaynomics/opencode-ruby).
|
||||
|
||||
> **Alpha software.** API will change before 1.0. Pin to a specific version.
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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/numeric/time" # 2.seconds, 5.minutes, etc.
|
||||
|
||||
require_relative "opencode/rails_version"
|
||||
require_relative "opencode/rails/version"
|
||||
require_relative "opencode/error_reporter"
|
||||
|
||||
# Tier 4 leaves (no deps on other rails-gem files)
|
||||
|
||||
@@ -9,10 +9,10 @@ module Opencode
|
||||
# - Opencode::Exchange.tool_artifacts — content lives inside a tool
|
||||
# call's input/metadata (write tool).
|
||||
# - Opencode::SandboxFile#as_artifact — identity conversion of a
|
||||
# sandbox-resident file (the default identity path).
|
||||
# sandbox-resident file (the default path for Blackline + Raven).
|
||||
#
|
||||
# Transforms also return Artifacts — e.g. a host-rendered HTML
|
||||
# artifact carrying a trust-metadata stamp.
|
||||
# Transforms also return Artifacts; that's why FlightResultsTransform
|
||||
# returns one with the host-rendered HTML + 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
|
||||
|
||||
@@ -65,13 +65,13 @@ module Opencode
|
||||
eligible = file_entries.reject { |e| e[:type] == "delete" }
|
||||
next if eligible.empty?
|
||||
|
||||
Opencode::Instrumentation.notify("opencode.apply_patch.artifacts_dropped",
|
||||
Opencode::Instrumentation.instrument("opencode.apply_patch.artifacts_dropped",
|
||||
file_count: eligible.size,
|
||||
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
|
||||
message_id: part[:messageID],
|
||||
session_id: part[:sessionID],
|
||||
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
|
||||
"extraction requires sandbox-read which is not yet wired into ResponseParser")
|
||||
"extraction requires sandbox-read which is not yet wired into ResponseParser") { }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,9 +11,9 @@ module Opencode
|
||||
# 1. A previous job retry attached the destination filename via the
|
||||
# tool-extracted path (the agent wrote a file with that name and
|
||||
# it landed before the trusted render did).
|
||||
# 2. A pre-substrate code path persisted an agent-authored file
|
||||
# under the destination filename — the same-name stored-XSS
|
||||
# attack the trust boundary exists to prevent.
|
||||
# 2. A pre-substrate code path persisted an agent-authored HTML file
|
||||
# with the destination filename (the historical AIGL exploit
|
||||
# surface that motivated the trust boundary in the first place).
|
||||
# 3. A previous transform version stamped different metadata and the
|
||||
# trust check now correctly rejects it.
|
||||
#
|
||||
|
||||
@@ -9,7 +9,7 @@ module Opencode
|
||||
#
|
||||
# Two-line usage:
|
||||
#
|
||||
# Opencode::MessageArtifacts.new(message: m, feature: "chat", transforms: [])
|
||||
# Opencode::MessageArtifacts.new(message: m, feature: "blackline", transforms: [])
|
||||
# .attach_from(exchange: exchange, sandbox: sandbox)
|
||||
#
|
||||
# All four phases (tool extract, transform routing, impostor purge,
|
||||
@@ -24,14 +24,15 @@ module Opencode
|
||||
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.
|
||||
# :all — Blackline/Raven default. Every safe sandbox file that
|
||||
# no transform claims falls through to identity attach.
|
||||
# The agent's `write` outputs are final document bytes the
|
||||
# host serves back unchanged.
|
||||
# :none — AIGL. The agent's sandbox is full of internal working
|
||||
# scratch (notes.md, map.md, timeline.md) plus the one
|
||||
# file the transform claims (flight-results.json). Only
|
||||
# transform-claimed files attach; everything else stays
|
||||
# agent-internal.
|
||||
def initialize(message:, feature:, transforms: [], default_attach: :all,
|
||||
max_sandbox_files: MAX_SANDBOX_ARTIFACTS)
|
||||
@message = message
|
||||
@@ -81,10 +82,11 @@ module Opencode
|
||||
if (transform = transforms.find { |t| t.applies_to?(file) })
|
||||
attached += 1 if apply_transform(transform, file)
|
||||
elsif default_attach == :all
|
||||
# Default identity path: 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.
|
||||
# Default identity path. Blackline/Raven default — every safe
|
||||
# sandbox file that no transform claims attaches as-is. AIGL
|
||||
# passes default_attach: :none so non-transform files (the
|
||||
# agent's notes.md / map.md / timeline.md scratch) don't
|
||||
# auto-attach.
|
||||
attached += 1 if file.as_artifact.attach_to(message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
# We can't reuse the same constant from a second gem, so we use a
|
||||
# distinct, non-namespaced constant.
|
||||
module Opencode
|
||||
RAILS_VERSION = "0.0.1.alpha2"
|
||||
RAILS_VERSION = "0.0.1.alpha1"
|
||||
end
|
||||
@@ -32,9 +32,9 @@ module Opencode
|
||||
|
||||
# Yields SandboxFile values for every file in the sandbox that
|
||||
# passes its own #safe? predicate AND was modified after the cutoff.
|
||||
# When `after:` is nil (callers without a user_message handle —
|
||||
# e.g. finalize paths that scan the whole sandbox), no mtime filter
|
||||
# is applied — only safety + filetype.
|
||||
# When `after:` is nil (callers without a user_message handle, e.g.
|
||||
# AIGL on certain finalize paths), no mtime filter is applied —
|
||||
# only safety + filetype.
|
||||
def files(after: nil)
|
||||
return enum_for(:files, after: after) unless block_given?
|
||||
return unless exists?
|
||||
|
||||
@@ -68,8 +68,8 @@ module Opencode
|
||||
|
||||
# Identity conversion: this sandbox file → an Artifact carrying the
|
||||
# file's own bytes. Used by the substrate's default (non-transform)
|
||||
# path, where the agent writes document bytes directly to the
|
||||
# sandbox and the host serves them back unchanged.
|
||||
# path for Blackline + Raven, whose agents write document bytes
|
||||
# directly to the sandbox and expect them attached unchanged.
|
||||
def as_artifact
|
||||
Artifact.new(
|
||||
filename: basename,
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
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.
|
||||
# Three near-identical implementations of this lifecycle existed on
|
||||
# Blackline::Conversation, Raven::Conversation, and AIGL::Trip. Each
|
||||
# had subtle differences (Blackline and Raven didn't take a row-level
|
||||
# lock; AIGL did). Sandi Metz flagged the shotgun surgery in the
|
||||
# architectural review — a change to the lifecycle had to be made in
|
||||
# three places that looked alike but disagreed on locking. This PORO
|
||||
# is the consolidated role.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
@@ -22,10 +23,11 @@ module Opencode
|
||||
# service.abort!(client) # best-effort upstream abort
|
||||
#
|
||||
# The permissions_for: callable receives the record at mint! time and
|
||||
# returns the permissions array for client.create_session. Per-product
|
||||
# scoping (e.g. a record's workspace_key or tenant_id branching) lives
|
||||
# returns the permissions array for client.create_session. Product-
|
||||
# specific scoping (e.g. AIGL's workspace_key/trip_id branching) lives
|
||||
# in the caller's lambda, not in this class — that keeps Session free
|
||||
# of any reference to permission-building helpers.
|
||||
# of any reference to permission-building helpers and preserves the
|
||||
# rails-tier -> containers-tier boundary the design doc locks in.
|
||||
#
|
||||
# The on_error: callable is invoked when abort! catches an
|
||||
# Opencode::Error during teardown. Callers wire their own observability
|
||||
|
||||
@@ -9,8 +9,8 @@ module Opencode
|
||||
# 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.
|
||||
# so Blackline views, AIGL views, and any future OpenCode-backed
|
||||
# feature can render tool calls consistently.
|
||||
#
|
||||
# ## Data shape (Opencode::Reply writes this into `parts_json`)
|
||||
#
|
||||
|
||||
@@ -6,16 +6,13 @@ module Opencode
|
||||
# agent wrote" and "bytes the host signs and attaches."
|
||||
#
|
||||
# The default substrate path is identity: any sandbox file the
|
||||
# allowlist accepts gets attached as-is. 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.
|
||||
# allowlist accepts gets attached as-is. Blackline and Raven use
|
||||
# the default — their agents `write` final document bytes the host
|
||||
# serves back unchanged. AIGL's contract is structurally different:
|
||||
# the agent writes JSON, the **host** must render that JSON into
|
||||
# trusted HTML before attaching, because the resulting HTML gets
|
||||
# served inline from the app origin and an agent-written filename
|
||||
# can't be permitted as stored-XSS.
|
||||
#
|
||||
# Subclass hooks (override these — none have a generic default
|
||||
# that's safe to inherit):
|
||||
|
||||
@@ -36,8 +36,8 @@ module Opencode
|
||||
# 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.
|
||||
# collaborator passed in. Turn never sees Blackline, Raven, or AIGL by
|
||||
# name.
|
||||
#
|
||||
# Collaborators
|
||||
# -------------
|
||||
@@ -50,7 +50,7 @@ module Opencode
|
||||
#
|
||||
# observer_factory callable: ->(message) returning an observer that
|
||||
# responds to #watch(reply). Concretely:
|
||||
# ->(message) { MyApp::ReplyStream.new(message: message) }.
|
||||
# ->(message) { Blackline::ReplyStream.new(...) }.
|
||||
#
|
||||
# system_context callable: ->(subject) -> String system prompt.
|
||||
#
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "lib/opencode/rails_version"
|
||||
require_relative "lib/opencode/rails/version"
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = "opencode-rails"
|
||||
spec.version = Opencode::RAILS_VERSION
|
||||
spec.authors = ["Ajay Krishnan"]
|
||||
spec.email = ["opencode-rails@ajay.to"]
|
||||
spec.email = ["ajay@krishnan.ca"]
|
||||
|
||||
spec.summary = "Production-grade Rails integration for OpenCode."
|
||||
spec.description = <<~DESC
|
||||
@@ -19,25 +19,22 @@ Gem::Specification.new do |spec|
|
||||
production-grade OpenCode streaming without rolling your own
|
||||
boilerplate.
|
||||
DESC
|
||||
spec.homepage = "https://github.com/ajaynomics/opencode-rails"
|
||||
spec.homepage = "https://gitea.krishnan.ca/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["changelog_uri"] = "#{spec.homepage}/src/branch/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"
|
||||
# this gem builds on. Versions are kept in lockstep during the alpha
|
||||
# phase; will relax to a looser pessimistic pin once both gems stabilize.
|
||||
spec.add_runtime_dependency "opencode-ruby", "~> 0.0.1.alpha1"
|
||||
|
||||
# Rails sub-libraries used at runtime. Depending on these individually
|
||||
# (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
|
||||
# (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.
|
||||
# the host's ActiveStorage-backed AIGL::Trip/etc. 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(
|
||||
|
||||
@@ -47,29 +47,4 @@ class Opencode::ErrorReporterTest < Minitest::Test
|
||||
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
|
||||
assert invoked, "Adapter should be invoked even with no kwargs"
|
||||
end
|
||||
|
||||
def test_adapter_exceptions_propagate
|
||||
# If the host's adapter itself raises (Honeybadger HTTP failure,
|
||||
# Sentry quota error, etc.) the gem must propagate — silently
|
||||
# swallowing the adapter's own errors would hide an outage from
|
||||
# operators who think their error tracker is healthy.
|
||||
Opencode::ErrorReporter.adapter = ->(_error, **_opts) {
|
||||
raise StandardError, "adapter blew up"
|
||||
}
|
||||
|
||||
raised = assert_raises(StandardError) do
|
||||
Opencode::ErrorReporter.report(RuntimeError.new("original"))
|
||||
end
|
||||
assert_equal "adapter blew up", raised.message
|
||||
end
|
||||
|
||||
def test_report_returns_adapter_return_value
|
||||
# Useful for hosts wanting Rails.error.report's standard return
|
||||
# (the error itself). Verifies the call shape doesn't transform it.
|
||||
sentinel = Object.new
|
||||
Opencode::ErrorReporter.adapter = ->(_error, **_opts) { sentinel }
|
||||
|
||||
result = Opencode::ErrorReporter.report(StandardError.new("x"))
|
||||
assert_same sentinel, result
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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,24 +36,16 @@ class Opencode::LoadingTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
|
||||
# We check via path match on both directory ("/opencode-rails/") and
|
||||
# installed-gem name ("/opencode-rails-VERSION/") so the assertion is
|
||||
# robust to either a sibling-repo dev setup or a bundle-resolved gem
|
||||
# install.
|
||||
GEM_PATH_PATTERN = ->(name) { %r{/#{Regexp.escape(name)}[-/]} }
|
||||
|
||||
def test_session_constant_points_at_this_gem
|
||||
location = Opencode::Session.instance_method(:initialize).source_location.first
|
||||
assert_match GEM_PATH_PATTERN.call("opencode-rails"), location,
|
||||
assert_match %r{/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,
|
||||
assert_match %r{/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
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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
|
||||
@@ -1,62 +0,0 @@
|
||||
# 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
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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
|
||||
@@ -1,62 +0,0 @@
|
||||
# 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
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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