Initial public release v0.0.1.alpha2
Some checks failed
Test / test (3.2) (push) Failing after 9m43s
Test / test (3.3) (push) Failing after 9m43s
Test / test (3.4) (push) Failing after 9m42s

opencode-ruby — idiomatic Ruby client for OpenCode (HTTP + SSE).

Hand-rolled, opinionated Ruby SDK with block-form streaming, value-
object responses, and automatic SSE reconnection. Pluggable
Opencode::Instrumentation adapter for routing events to
ActiveSupport::Notifications, OpenTelemetry, stdout, or any custom
emitter. Companion to opencode-rails for AR-coupled Rails apps.

What this version ships:
  - Opencode::Client (Net::HTTP + SSE)
  - Opencode::Reply / Reply::Result / ReplyObserver
  - Opencode::Tracer, Opencode::Prompts
  - Opencode::ResponseParser, ToolPart, PartSource, Todo
  - Opencode::Instrumentation (instrument + notify)
  - Opencode::Error and seven subclasses
  - examples/conversation_recipe.rb — canonical Rails wiring blueprint

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

Ruby >= 3.2. Runtime dep: activesupport >= 6.1, < 9.0.

See CHANGELOG.md for the alpha1 -> alpha2 delta.
This commit is contained in:
2026-05-20 21:41:30 -07:00
commit 889d38332f
24 changed files with 2616 additions and 0 deletions

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

@@ -0,0 +1,34 @@
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-ruby.gemspec
- name: Verify gem loads after install
run: |
gem install --local opencode-ruby-*.gem
ruby -ropencode-ruby -e 'puts Opencode::VERSION'

8
.gitignore vendored Normal file
View File

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

51
CHANGELOG.md Normal file
View File

@@ -0,0 +1,51 @@
# Changelog
## 0.0.1.alpha2 — 2026-05-20
### Added
- `Opencode::Instrumentation.notify(name, payload)` — fire-and-forget
emission for point-in-time events that don't need duration measurement
(apply_patch.artifacts_dropped, session.recreated, etc.). Adapter
receives an empty block so AS::Notifications-shaped sinks see a
zero-duration event. Complements the existing block-form
`.instrument(name, payload) { ... }`.
### Why
The block-form `.instrument(name, payload) { }` with an empty block was
awkward at fire-and-forget call sites in opencode-rails. Two named
verbs (`instrument` for wrap-a-block, `notify` for fire-and-forget)
match the host-side mental model and read better at the call site.
## 0.0.1.alpha1 — Unreleased
First public alpha. HTTP + SSE client for OpenCode REST API.
### What's in
- `Opencode::Client` — Net::HTTP-based HTTP client with SSE streaming + automatic reconnection.
- `#create_session(title:, permissions:)`, `#get_messages(session_id)`, `#list_sessions`, `#delete_session(id)`, `#abort_session(id)`.
- `#send_message(session_id, text, model:, ...)` — synchronous send-and-poll.
- `#send_message_async(session_id, text, ...)` — async send.
- `#stream(session_id, text, ...) { |part| ... } → Opencode::Reply::Result`**the headline.** Block-form streaming with internal Reply accumulation and final-exchange merge.
- `#stream_events(session_id:, ...) { |event| ... }` — lower-level SSE event firehose for power users.
- `#reply_question(request_id:, answers:)` / `#reply_permission(request_id:, reply:)` — answer interactive prompts.
- `Opencode::Reply` — live state machine accumulating SSE events into the assistant's reply. Documented observer protocol (`Opencode::ReplyObserver`).
- `Opencode::Reply::Result` — typed Struct value object returned by `Client#stream` and `Reply#result`. Fields: `:parts_json`, `:full_text`, `:reasoning_text`, `:tool_parts`.
- `Opencode::Instrumentation` — pluggable adapter (default no-op). Plug in `ActiveSupport::Notifications`, OpenTelemetry, stdout, etc.
- `Opencode::ResponseParser`, `Opencode::ToolPart`, `Opencode::PartSource`, `Opencode::Todo` — wire-format helpers used by `Reply` and reusable by callers building their own SSE handling.
- `Opencode::Prompts` — per-Reply registry of pending question/permission prompts (used by `Reply` internally; exposed for callers that need to peek).
- `Opencode::Tracer` — callable that prefixes event names before forwarding to a host emitter.
- Error hierarchy: `Opencode::Error` and seven subclasses (`ConnectionError`, `TimeoutError`, `SessionNotFoundError`, `StaleSessionError`, `IdleStreamError`, `ServerError`, `BadRequestError`).
### What's out
- ActiveRecord-backed session lifecycle, `acts_as_opencode_session`, generators — deferred to `opencode-rails` if external demand materializes. See `examples/conversation_recipe.rb` for the canonical Rails wiring pattern.
- Multi-tenant per-user Docker container orchestration — application glue, not a gem's concern.
### Compatibility
- Ruby ≥ 3.2
- OpenCode server ≥ 1.15 (tested against the message bus schema in `packages/opencode/src/session/message-v2.ts`)
- Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem.

3
Gemfile Normal file
View File

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

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 ajaynomics
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# opencode-ruby
Idiomatic Ruby client for [OpenCode](https://opencode.ai). Block-form streaming, value-object responses, automatic SSE reconnection.
```ruby
require "opencode-ruby"
client = Opencode::Client.new(base_url: "http://localhost:4096")
session = client.create_session(title: "My session")
reply = client.stream(session[:id], "Explain monads in two sentences.") do |part|
print part["content"] if part["type"] == "text"
end
puts
puts reply.full_text
puts "(#{reply.tool_parts.size} tool calls, #{reply.parts_json.size} parts total)"
```
Three lines of setup, four lines of work. Block fires every time a part appears, grows, finalizes, or (for tool calls) advances state. The final return value is a typed `Opencode::Reply::Result` you can persist or inspect.
## Install
```ruby
# Gemfile
gem "opencode-ruby"
```
Or:
```sh
gem install opencode-ruby
```
Then `require "opencode-ruby"`.
## Configuration
```ruby
client = Opencode::Client.new(
base_url: "http://localhost:4096", # or ENV["OPENCODE_BASE_URL"]
password: "secret", # or ENV["OPENCODE_SERVER_PASSWORD"]
timeout: 120 # or ENV["OPENCODE_TIMEOUT"], seconds
)
```
Multi-tenant apps construct multiple clients with different `base_url`s — each `Opencode::Client` holds its own Net::HTTP connection, no shared state.
## Core API
### Streaming (the headline)
```ruby
reply = client.stream(session_id, "What's 2 + 2?") do |part|
case part["type"]
when "text" then print part["content"]
when "reasoning" then # ignore, or render in a separate UI
when "tool" then puts " [tool: #{part['tool']}#{part['status']}]"
end
end
reply.full_text # => "2 + 2 = 4."
reply.tool_parts # => array of terminal tool-call parts
reply.reasoning_text # => the model's hidden reasoning, if any
reply.parts_json # => the full ordered parts array, ready for persistence
```
### Synchronous send (no streaming)
```ruby
result = client.send_message(session_id, "Quick yes/no: is Ruby fun?")
# result is the OpenCode response hash; see API docs for fields.
```
### Lower-level event firehose
If you need raw SSE events (every server tick, todo update, prompt asked/replied), use `stream_events` directly:
```ruby
client.stream_events(session_id: session_id) do |event|
puts event[:type] # "message.part.delta", "todo.updated", "session.idle", ...
end
```
### Interactive prompts
When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via:
```ruby
client.reply_question(request_id: "que_...", answers: [["yes"]])
client.reply_permission(request_id: "per_...", reply: "always")
```
## Error model
Every method that hits the network raises `Opencode::Error` (or a subclass) on failure. Catch the parent or the specific subclass:
```ruby
begin
client.health
rescue Opencode::ConnectionError # server unreachable
rescue Opencode::TimeoutError # client-side timeout
rescue Opencode::SessionNotFoundError # 404 on a session
rescue Opencode::StaleSessionError # session.idle never arrived
rescue Opencode::IdleStreamError # mid-turn SSE wedge
rescue Opencode::ServerError # 5xx
rescue Opencode::BadRequestError # 4xx other than 404
rescue Opencode::Error # catch-all
end
```
## Instrumentation
Want to see what the gem is doing? Plug in an adapter. Default behaviour is silent no-op — the gem ships zero opinion about your observability stack.
```ruby
# stdout for debugging:
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
puts "[#{name}] #{payload.inspect}"
block.call
}
# ActiveSupport::Notifications in a Rails app:
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
ActiveSupport::Notifications.instrument(name, payload, &block)
}
```
Event names emitted today:
| Event | Payload |
|---|---|
| `opencode.request` | `:method`, `:path` |
## Want this in a Rails app?
See [`examples/conversation_recipe.rb`](examples/conversation_recipe.rb) for a ~60-line plain-ActiveRecord blueprint covering session lifecycle (`with_lock`, `update_columns` mid-stream snapshots, CAS-safe finalize). Drop it into your app and adapt.
If enough Rails developers do that and want it as a one-liner, we'll ship `opencode-rails` with `acts_as_opencode_session`. **File an issue if that's you** — your issue is the signal.
## Position against `opencode_client`
Want every OpenCode endpoint auto-generated from the OpenAPI spec? Use [`opencode_client`](https://rubygems.org/gems/opencode_client). This gem is the hand-rolled idiomatic alternative — smaller surface, opinionated defaults, block-form streaming. Pick whichever fits how you want to write Ruby.
## Compatibility
- Ruby ≥ 3.2
- OpenCode server ≥ 1.15
- Runtime dependency: `activesupport (>= 6.1)`*not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.).
## Development
```sh
bundle install
bundle exec rake test
```
12-test smoke covers Client end-to-end against WebMock-stubbed OpenCode endpoints.
## License
MIT. See [LICENSE](LICENSE).

12
Rakefile Normal file
View File

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

View File

@@ -0,0 +1,153 @@
# frozen_string_literal: true
# Rails integration recipe — copy + adapt.
#
# This is NOT part of opencode-ruby. It's the canonical pattern showing
# how to wire the gem's primitives into a Rails ActiveRecord app. Drop
# this file into your `app/models/` (rename it), adapt the schema, and
# you have a working block-streaming chat with row-locked session
# lifecycle and CAS-safe finalize.
#
# What this recipe demonstrates:
#
# 1. Schema (below in a comment) — the migration you'll need
# 2. Session lifecycle — idempotent ensure! with with_lock
# 3. Mid-stream parts persistence via update_columns (bypasses
# AR callbacks so Turbo broadcasts don't fire per-part)
# 4. CAS-safe finalize — concurrent cancel wins
# 5. Recovery from SessionNotFoundError — recreate once + retry
#
# If you want this as a one-liner (`acts_as_opencode_session`), open
# an issue on the repo. The gem ships this recipe instead of a concern
# because the right shape depends on the host app's conventions — and
# shipping a half-built concern is worse than shipping a clear
# blueprint you can adapt.
#
# Suggested schema (adapt naming to your domain):
#
# create_table :conversations do |t|
# t.references :user, null: false, foreign_key: true
# t.string :title
# t.string :opencode_session_id
# t.timestamps
# t.index :opencode_session_id, unique: true,
# where: "opencode_session_id IS NOT NULL" # partial unique
# end
#
# create_table :messages do |t|
# t.references :conversation, null: false, foreign_key: true
# t.string :role, null: false # "user" or "assistant"
# t.integer :status, null: false, default: 0 # see enum below
# t.text :content, null: false, default: ""
# t.json :parts_json, null: false, default: []
# t.json :tool_calls_json, null: false, default: []
# t.decimal :cost, precision: 10, scale: 6
# t.integer :input_tokens
# t.integer :output_tokens
# t.timestamps
# end
class Conversation < ApplicationRecord
belongs_to :user
has_many :messages, dependent: :destroy
# Returns the OpenCode session id for this conversation, creating one
# if needed. Idempotent. Race-safe via row-lock + double-check.
def ensure_opencode_session!(client)
return opencode_session_id if opencode_session_id.present?
with_lock do
return opencode_session_id if opencode_session_id.present?
session = client.create_session(title: title)
update!(opencode_session_id: session[:id] || session["id"])
end
opencode_session_id
rescue ActiveRecord::RecordNotUnique
# Another worker raced past the partial unique index. Loser reloads.
reload
opencode_session_id
end
# Replace a stale upstream session. Used by SessionNotFoundError
# recovery in the streaming job below.
def recreate_opencode_session!(client)
pre_id = opencode_session_id
with_lock do
return opencode_session_id if opencode_session_id.present? && opencode_session_id != pre_id
session = client.create_session(title: title)
update!(opencode_session_id: session[:id] || session["id"])
end
opencode_session_id
end
end
class Message < ApplicationRecord
belongs_to :conversation
enum status: { pending: 0, streaming: 1, completed: 2, cancelled: 3, errored: 4 }
end
# The streaming job. Compose Opencode::Client + ActiveRecord; that's it.
class GenerateAssistantReplyJob < ApplicationJob
def perform(message_id, user_prompt)
message = Message.find(message_id)
return unless message.pending?
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_BASE_URL"),
password: ENV["OPENCODE_SERVER_PASSWORD"]
)
session_id = message.conversation.ensure_opencode_session!(client)
message.update!(status: :streaming)
attempted_recreate = false
begin
reply = client.stream(session_id, user_prompt) do |part|
# Mid-stream snapshot: update_columns bypasses AR callbacks so
# an after_update_commit broadcasts_refreshes_to(conversation)
# doesn't fire per-part and clobber per-part Turbo broadcasts
# you might be doing separately. The final write below uses
# update! to fire callbacks deliberately.
message.update_columns(
parts_json: reply_parts_so_far(part, message),
updated_at: Time.current
)
end
# CAS-safe finalize: only land the final state if no concurrent
# cancel got there first.
message.with_lock do
return unless message.reload.pending? || message.streaming?
message.update!(
status: :completed,
content: reply.full_text,
parts_json: reply.parts_json,
tool_calls_json: reply.tool_parts
)
end
rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError
raise if attempted_recreate
message.conversation.recreate_opencode_session!(client)
attempted_recreate = true
retry
end
rescue StandardError => e
message&.update!(status: :errored, content: "An error occurred: #{e.message.truncate(200)}")
end
private
# Builds the parts array up to (and including) the current part by
# poking the gem's internal Reply state. In practice you'd capture
# the Reply instance from the block via a closure, OR derive from
# `part` if you only need the latest part.
def reply_parts_so_far(part, message)
parts = (message.parts_json || []).dup
# Trivial dedup: replace or append by part id, if your wire-format
# includes one. For real merge logic, lift Opencode::Reply's
# part_index_by_id / append_part pattern.
parts << part unless parts.any? { |existing| existing["id"] == part["id"] }
parts
end
end

26
lib/opencode-ruby.rb Normal file
View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
# Minimal ActiveSupport surface — `present?`, `blank?`, `presence`,
# `truncate`, `duplicable?`. We deliberately load only the core_ext bits
# we use, not all of activesupport, to keep the boot footprint small in
# non-Rails apps.
require "active_support/core_ext/object/blank" # provides blank?, present?, presence
require "active_support/core_ext/object/duplicable"
require "active_support/core_ext/string/filters" # provides String#truncate
require "active_support/core_ext/numeric/bytes" # provides Integer#megabytes
require_relative "opencode/version"
require_relative "opencode/error"
require_relative "opencode/instrumentation"
require_relative "opencode/response_parser"
require_relative "opencode/part_source"
require_relative "opencode/tool_part"
require_relative "opencode/todo"
require_relative "opencode/prompts"
require_relative "opencode/reply_observer"
require_relative "opencode/reply"
require_relative "opencode/tracer"
require_relative "opencode/client"
module Opencode
end

564
lib/opencode/client.rb Normal file
View File

@@ -0,0 +1,564 @@
# frozen_string_literal: true
require "net/http"
require "json"
require "base64"
module Opencode
# HTTP client for OpenCode REST API.
# Thread safety: Each instance creates its own Net::HTTP connection.
# Do NOT share instances across threads. Create per-job.
class Client
attr_reader :directory
def initialize(
base_url: ENV["OPENCODE_BASE_URL"] || "http://localhost:4096",
password: ENV["OPENCODE_SERVER_PASSWORD"],
timeout: (ENV["OPENCODE_TIMEOUT"] || 120).to_i,
directory: nil,
workspace: nil
)
@uri = URI.parse(base_url)
@password = password
@timeout = timeout || 120
@directory = directory
@workspace = workspace
end
def create_session(title: nil, permissions: nil)
body = { title: title, permission: permissions }.compact
post("/session", body)
end
def send_message(
session_id, text,
parts: nil,
model: nil,
agent: nil,
system: nil,
message_id: nil,
no_reply: nil,
tools: nil,
format: nil,
variant: nil
)
body = prompt_payload(
text,
parts: parts,
model: model,
agent: agent,
system: system,
message_id: message_id,
no_reply: no_reply,
tools: tools,
format: format,
variant: variant
)
post("/session/#{session_id}/message", body)
end
def send_message_async(
session_id, text,
parts: nil,
model: nil,
agent: nil,
system: nil,
message_id: nil,
no_reply: nil,
tools: nil,
format: nil,
variant: nil
)
body = prompt_payload(
text,
parts: parts,
model: model,
agent: agent,
system: system,
message_id: message_id,
no_reply: no_reply,
tools: tools,
format: format,
variant: variant
)
post("/session/#{session_id}/prompt_async", body)
end
# Block-form streaming — the headline API for callers who want the
# full async-prompt + SSE-loop + final-exchange-merge flow in one
# call. Returns the final Opencode::Reply::Result value object once
# the agent finishes.
#
# reply = client.stream(session_id, "Explain monads") do |part|
# print part["content"] if part["type"] == "text"
# end
# reply.full_text # => the final accumulated text
# reply.tool_parts # => array of terminal tool parts
#
# The block is invoked every time a part is added, grows, finalizes,
# or (for tool parts) advances state — i.e., whenever a user-visible
# change happens. The block receives the current `part` hash (string
# keys: "type", "content", "tool", "status", "input", ...).
#
# If you need raw events (every server.* tick, todo.updated, prompt
# asked/replied, etc.), use #stream_events instead.
#
# Optional kwargs are forwarded to send_message_async — model, agent,
# system prompt override, and the SSE pacing knobs supported by
# stream_events.
def stream(
session_id, text,
model: nil, agent: nil, system: nil, message_id: nil,
stream_timeout: 600,
first_event_timeout: 120,
idle_stream_timeout: nil,
on_activity_tick: nil,
&block
)
send_message_async(
session_id, text,
model: model, agent: agent, system: system, message_id: message_id
)
reply = Opencode::Reply.new
reply.add_observer(StreamBlockObserver.new(&block)) if block_given?
stream_events(
session_id: session_id,
timeout: stream_timeout,
first_event_timeout: first_event_timeout,
idle_stream_timeout: idle_stream_timeout,
reply: reply,
on_activity_tick: on_activity_tick
) do |event|
reply.apply(event)
end
merge_final_exchange(session_id, reply)
reply.result
end
def list_sessions
uri = build_uri("/session")
request = Net::HTTP::Get.new(uri)
execute(request)
end
def children(session_id)
uri = build_uri("/session/#{session_id}/children")
request = Net::HTTP::Get.new(uri)
execute(request)
end
def delete_session(session_id)
uri = build_uri("/session/#{session_id}")
request = Net::HTTP::Delete.new(uri)
execute(request)
end
def session_status
uri = build_uri("/session/status")
request = Net::HTTP::Get.new(uri)
execute(request)
end
def get_messages(session_id)
uri = build_uri("/session/#{session_id}/message")
request = Net::HTTP::Get.new(uri)
execute(request)
end
def abort_session(session_id)
post("/session/#{session_id}/abort", {})
end
def reply_question(request_id:, answers:)
post("/question/#{request_id}/reply", { answers: answers })
end
def reject_question(request_id:)
post("/question/#{request_id}/reject", {})
end
def reply_permission(request_id:, reply:, message: nil)
body = { reply: reply }
body[:message] = message if message.present?
post("/permission/#{request_id}/reply", body)
end
# Returns pending question requests as an Array of Hashes with
# SYMBOL keys, consistent with every other endpoint that flows
# through handle_response (e.g., health, list_sessions, get_messages).
# Callers that compare against persisted JSON column data should
# symbolize their side, not desymbolize this side.
def list_questions
uri = build_uri("/question")
request = Net::HTTP::Get.new(uri)
add_auth_header(request)
response = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
http_client.request(request)
end
unless response.code.to_i.between?(200, 299)
raise ServerError, "list_questions failed: HTTP #{response.code}#{response.body.to_s[0, 200]}"
end
return [] if response.body.blank?
JSON.parse(response.body, symbolize_names: true)
rescue JSON::ParserError => e
raise ServerError, "list_questions returned invalid JSON: #{e.message}"
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
rescue Errno::ECONNREFUSED, SocketError => e
raise ConnectionError, "OpenCode unreachable: #{e.message}"
end
def health
uri = build_uri("/global/health", scoped: false)
request = Net::HTTP::Get.new(uri)
execute(request)
end
MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses
SSE_RECONNECT_DELAY = 0.1
TRANSIENT_SSE_ERRORS = [
EOFError,
IOError,
Net::OpenTimeout,
Net::ReadTimeout,
Errno::ECONNREFUSED,
Errno::ECONNRESET,
Errno::EPIPE
].freeze
# Opens SSE connection to GET /event, yields parsed events filtered by session_id.
# Blocks until session goes idle or timeout, reconnecting across dropped
# event-stream connections.
#
# first_event_timeout: seconds to wait for a session-specific event before
# declaring the session stale. Server heartbeats don't count — they're global
# keep-alives that flow regardless of session state.
#
# Default 120s rather than the more aggressive 30s used originally:
# slow-thinking reasoning models (Kimi K2, GPT-5 with extended thinking,
# etc.) routinely spend 30-90s of pure reasoning before emitting their
# first `message.part.*` event, especially on cold sessions with long
# system prompts. 30s false-positive trips on legitimate first turns
# and converts them to `StaleSessionError`; 120s catches genuine zombies
# without nuking real reasoning. Callers that know their agent is
# short-prompt + fast can pass a lower value.
#
# idle_stream_timeout: seconds to wait BETWEEN meaningful events once
# the session has started producing them. Default nil = no check
# (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat
# watchdog for callers whose user-facing surface needs to fail fast
# rather than sit forever when an upstream LLM stream wedges mid-turn.
# Distinct from first_event_timeout (which only protects cold-start)
# and from the overall `timeout` ceiling of 600s (which is forgiving
# — a hung stream holding a thread for 10 minutes is already a bad
# UX). When the window is exceeded the call raises
# Opencode::IdleStreamError, which the caller is expected to catch and
# translate into a user-visible error / retry affordance.
def stream_events(session_id:, timeout: 600, first_event_timeout: 120,
idle_stream_timeout: nil,
reply: nil, on_activity_tick: nil, &block)
uri = build_uri("/event")
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout
received_session_event = false
last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
loop do
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
deadline = check_deadline_or_suspend(now, deadline, timeout, reply)
# NOTE: first_event_deadline is *not* suspension-eligible. If the agent
# never gets started we want to fail fast — a session that's blocked on
# a prompt has, by definition, already produced events.
if !received_session_event && now > first_event_deadline
raise StaleSessionError, "No events for session #{session_id} within #{first_event_timeout}s"
end
if idle_stream_timeout && received_session_event &&
(now - last_meaningful_event_at) > idle_stream_timeout
raise IdleStreamError,
"No meaningful events for session #{session_id} within #{idle_stream_timeout}s " \
"(SSE heartbeats still arriving — upstream likely wedged mid-turn)"
end
request = Net::HTTP::Get.new(uri)
request["Accept"] = "text/event-stream"
request["Cache-Control"] = "no-cache"
add_auth_header(request)
http = Net::HTTP.new(@uri.host, @uri.port)
http.use_ssl = @uri.scheme == "https"
http.open_timeout = 10
http.read_timeout = 30
begin
buffer = String.new
http.request(request) do |response|
unless response.is_a?(Net::HTTPSuccess)
raise ServerError, "SSE connection failed: HTTP #{response.code}"
end
response.read_body do |chunk|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
deadline = check_deadline_or_suspend(now, deadline, timeout, reply)
if !received_session_event && now > first_event_deadline
raise StaleSessionError, "No events for session #{session_id} within #{first_event_timeout}s"
end
if idle_stream_timeout && received_session_event &&
(now - last_meaningful_event_at) > idle_stream_timeout
raise IdleStreamError,
"No meaningful events for session #{session_id} within #{idle_stream_timeout}s " \
"(SSE heartbeats still arriving — upstream likely wedged mid-turn)"
end
buffer << chunk
if buffer.bytesize > MAX_SSE_BUFFER
raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes"
end
while (idx = buffer.index("\n\n"))
raw_event = buffer.slice!(0, idx + 2)
event = parse_sse_event(raw_event, session_id)
next unless event
unless event[:type]&.start_with?("server.")
received_session_event = true
last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
# Tick activity on EVERY event, including server.heartbeat —
# that's the whole point: a healthy long wait (user thinking
# for 30 minutes) keeps the container warm via heartbeats so
# the reaper doesn't kill it mid-wait.
on_activity_tick&.call(event)
block.call(event)
return if event[:type] == "session.idle"
end
end
end
rescue *TRANSIENT_SSE_ERRORS
# Treat transport-level SSE disconnects like clean EOF: reconnect
# until session.idle, the overall timeout, or first-event timeout.
ensure
begin
http&.finish if http&.started?
rescue IOError
# Connection already closed — network partition or server shutdown
end
end
cutoff = received_session_event ? deadline : first_event_deadline
sleep_for = [ SSE_RECONNECT_DELAY, cutoff - Process.clock_gettime(Process::CLOCK_MONOTONIC) ].min
if sleep_for.positive?
sleep sleep_for
end
end
end
def close
@http&.finish if @http&.started?
rescue IOError
# already closed
end
private
# Best-effort merge of the polled message exchange into the live
# reply. Catches the stream-only / poll-only asymmetry — todo.updated
# is poll-only on some opencode versions; pure-streaming would miss
# the terminal todo state otherwise. If the session API is also down
# at this point (network partition, container teardown mid-call), we
# silently keep whatever the stream accumulated rather than raising;
# the caller's reply is still a usable Result either way.
def merge_final_exchange(session_id, reply)
exchange = get_messages(session_id)
last_assistant = Array(exchange).reverse_each.find do |message|
message.dig(:info, :role) == "assistant"
end
return unless last_assistant
polled = Opencode::ResponseParser.extract_interleaved_parts(last_assistant)
reply.sync_recovered_parts(polled) if polled.any?
rescue Opencode::Error
# Stream's result is still complete; the merge was a polish, not a
# requirement.
end
# Healthy wait: opencode is suspended on a question/permission deferred
# and heartbeats are keeping the connection alive. Reset the deadline
# to "from now" so the full stuck-stream protection is restored once
# the prompt resolves. Otherwise apply the normal deadline check.
def check_deadline_or_suspend(now, deadline, timeout, reply)
return now + timeout if reply&.prompt_blocked?
raise TimeoutError, "SSE stream timed out after #{timeout}s" if now > deadline
deadline
end
def prompt_payload(text, parts:, model:, agent:, system:, message_id:, no_reply:, tools:, format:, variant:)
message_parts = parts || [ { type: "text", text: text } ]
{
messageID: message_id,
parts: message_parts,
model: format_model(model),
agent: agent,
noReply: no_reply,
tools: tools,
format: format,
system: system,
variant: variant
}.compact
end
def format_model(model)
return nil unless model
return model if model.is_a?(Hash)
provider, model_id = model.split("/", 2)
{ providerID: provider, modelID: model_id }
end
def post(path, body)
uri = build_uri(path)
request = Net::HTTP::Post.new(uri)
request.body = body.to_json
execute(request)
end
def build_uri(path, scoped: true)
uri = @uri.dup
uri.path = path
if scoped
query = URI.decode_www_form(uri.query.to_s)
query << [ "directory", @directory ] if @directory.present?
query << [ "workspace", @workspace ] if @workspace.present?
uri.query = query.any? ? URI.encode_www_form(query) : nil
end
uri
end
def add_auth_header(request)
request["Content-Type"] = "application/json"
if @password.present?
request["Authorization"] = "Basic #{Base64.strict_encode64("opencode:#{@password}")}"
end
end
def execute(request)
add_auth_header(request)
response = nil
result = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
response = http_client.request(request)
handle_response(response)
end
result
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
rescue Errno::ECONNREFUSED, SocketError => e
raise ConnectionError, "OpenCode unreachable: #{e.message}"
end
def http_client
@http ||= Net::HTTP.new(@uri.host, @uri.port).tap do |http|
http.use_ssl = @uri.scheme == "https"
http.open_timeout = 10
http.read_timeout = @timeout
http.write_timeout = 30
end
end
def parse_sse_event(raw, session_id)
data_line = raw.lines.find { |l| l.start_with?("data: ") }
return nil unless data_line
json = JSON.parse(data_line.sub("data: ", "").strip, symbolize_names: true)
event_session = json.dig(:properties, :sessionID) ||
json.dig(:properties, :info, :sessionID) ||
json.dig(:properties, :part, :sessionID)
return json if json[:type] == "server.heartbeat"
return json if json[:type] == "server.connected"
return nil unless event_session == session_id
json
rescue JSON::ParserError
nil
end
def handle_response(response)
return {} if response.code.to_i == 204
body = if response.body.present?
JSON.parse(response.body, symbolize_names: true)
else
{}
end
case response.code.to_i
when 200..299 then body
when 400 then raise BadRequestError.new(error_message(body, "Bad request"), response: body)
when 404 then raise SessionNotFoundError.new(error_message(body, "Session not found"), response: body)
when 500..599 then raise ServerError.new(error_message(body, "Server error"), response: body)
else raise Error.new("Unexpected response: #{response.code}", response: body)
end
rescue JSON::ParserError
raise ServerError.new("Invalid JSON from OpenCode (HTTP #{response.code}): #{response.body&.truncate(200)}")
end
# OpenCode HTTP error bodies use a wrapped shape: { name:, data: { message:, kind?: } }.
# v1.14.51 stopped exposing internal defect details from the HTTP API, so
# `body[:message]` is no longer populated for errors — only `body[:data][:message]`.
# We read both to keep older mock servers working in tests.
def error_message(body, fallback)
body.dig(:data, :message) || body[:message] || fallback
end
end
# Internal Reply observer that bridges Reply's multi-callback protocol
# to a single user-supplied block for Client#stream. Each part-level
# callback (part_added, part_changed, part_finalized, tool_progressed)
# forwards the current part to the user's block.
#
# Non-part-level callbacks (step_finished, session_*, message_updated,
# todos_changed, question_*, permission_*) are intentionally NOT
# forwarded — they're either telemetry the gem owns internally, or
# interactive-protocol concerns that callers route through
# #stream_events directly when they need them.
class StreamBlockObserver
include Opencode::ReplyObserver
def initialize(&block)
@block = block
end
def part_added(part:, **)
@block.call(part)
end
def part_changed(part:, **)
@block.call(part)
end
def part_finalized(part:, **)
@block.call(part)
end
def tool_progressed(part:, **)
@block.call(part)
end
end
end

28
lib/opencode/error.rb Normal file
View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
module Opencode
class Error < StandardError
attr_reader :response
def initialize(message = nil, response: nil)
@response = response
super(message)
end
end
class ConnectionError < Error; end
class TimeoutError < Error; end
class SessionNotFoundError < Error; end
class StaleSessionError < Error; end
# Raised by stream_events when meaningful (non-`server.*`) events stop
# arriving for longer than the caller's `idle_stream_timeout` window,
# even though the SSE socket itself is still alive (heartbeats are
# still flowing). Distinct from StaleSessionError, which fires when
# the session never produced any events in the first place. This one
# fires when the session WAS producing events and then went silent —
# the classic "OpenAI stream wedged mid-turn while the SSE keep-
# alive ticks on" failure mode.
class IdleStreamError < Error; end
class ServerError < Error; end
class BadRequestError < Error; end
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
module Opencode
# Pluggable instrumentation adapter. opencode-ruby ships zero
# dependencies on Rails or any specific instrumentation library. Users
# plug in their own emitter:
#
# # ActiveSupport::Notifications (Rails apps):
# Opencode::Instrumentation.adapter = ->(name, payload, &block) {
# ActiveSupport::Notifications.instrument(name, payload, &block)
# }
#
# # stdout (debugging, non-Rails scripts):
# Opencode::Instrumentation.adapter = ->(name, payload, &block) {
# puts "[#{name}] #{payload.inspect}"
# block.call
# }
#
# When no adapter is set (default), instrumentation is a no-op pass-
# through that yields the block and returns its value. The Client emits
# events for HTTP requests, SSE stream lifecycle, and recovery paths.
#
# Event names the Client emits:
#
# - opencode.request — every HTTP request to OpenCode server
#
# If you wire a real adapter, the payload hash carries `:method` and
# `:path` for opencode.request. Other events may add fields in future
# versions; treat the payload as forward-compatible.
#
# Two emission shapes:
#
# .instrument(name, payload) { ... } — wrap a block; the duration
# of the block becomes part
# of the event (when the
# adapter is ActiveSupport::
# Notifications-shaped).
#
# .notify(name, payload) — fire-and-forget; no block,
# no duration. Use for
# point-in-time observations
# (e.g. "this artifact was
# dropped").
module Instrumentation
class << self
attr_accessor :adapter
end
# Yields the block, optionally routed through the adapter if one is
# set. Always returns the block's return value (so call sites can
# wrap their work transparently).
def self.instrument(name, payload = {})
return yield unless adapter
adapter.call(name, payload) { yield }
end
# Fire-and-forget event. No block, no return value (the adapter's
# return is ignored). Use for point-in-time observations where
# duration doesn't apply — apply_patch.artifacts_dropped,
# session.recreated, etc.
#
# Implementation: invokes the same adapter as #instrument but with
# an empty block. Hosts that adapt to ActiveSupport::Notifications
# will see a zero-duration event; hosts that adapt to a structured-
# event API (Rails.event.notify, OpenTelemetry span events) can
# detect the empty-block convention if they need to. Most hosts
# don't need to care.
def self.notify(name, payload = {})
return unless adapter
adapter.call(name, payload) { }
nil
end
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require "set"
module Opencode
# A Part's provenance — where it came from in the OpenCode wire model.
#
# Two source classes exist:
#
# - Wire parts: emitted by the OpenCode message-parts pipeline and
# echoed back by `GET /session/:id/message`. These are authoritative
# for finalization — when the final exchange poll lands, wire parts
# overwrite whatever streaming captured.
#
# - Stream-only parts: synthesized from bus events that OpenCode does
# NOT persist as message parts. The host's Opencode::Reply
# materializes them so per-product ReplyStream observers can render
# them through the same tool partials as real tool parts, and
# Opencode::Turn preserves them across exchange-finalization so the
# final assistant message keeps what the user watched live.
#
# `todo.updated` is the first stream-only source (OpenCode emits the
# full todo list on a bus event but never records it as a message part).
# Future sources land here too: add the constant, add it to STREAM_ONLY,
# both `Reply#append_part` callers and `Turn#stream_only_part?` keep
# working with no further edits.
#
# This module exists because the previous shape coupled Reply and Turn
# through a magic-string comparison of `metadata.source ==
# Opencode::Reply::TODO_STREAM_SOURCE`. Two classes carrying the same
# discriminator string is a "next time someone adds a source they'll
# only update one place" bug waiting to happen. The source-of-truth
# now lives here; both consumers go through `stream_only?(part)`.
module PartSource
TODO_UPDATED = "todo.updated"
STREAM_ONLY = Set[TODO_UPDATED].freeze
module_function
# True iff the part's metadata.source is one of the stream-only
# sources. Tolerates non-Hash input (returns false) so callers don't
# have to guard before asking.
def stream_only?(part)
return false unless part.is_a?(Hash)
STREAM_ONLY.include?(part.dig("metadata", "source"))
end
# Stamps `source:` into part_hash's metadata. Raises ArgumentError on
# an unknown source so typos surface at write time, not at the next
# `stream_only?` check (which would silently return false).
# Mutates and returns the input hash for chaining.
def stamp(part_hash, source:)
raise ArgumentError, "unknown stream-only source #{source.inspect}; " \
"register it in Opencode::PartSource::STREAM_ONLY first" unless STREAM_ONLY.include?(source)
part_hash["metadata"] ||= {}
part_hash["metadata"]["source"] = source
part_hash
end
end
end

87
lib/opencode/prompts.rb Normal file
View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
module Opencode
# Per-Reply registry of interactive prompts (questions + permissions)
# opencode has asked the user but not yet resolved. Lives on
# Opencode::Reply for the lifetime of one streaming turn.
#
# Two access patterns:
#
# * by request id ("que_..." or "per_...") — for the controller
# posting a user's answer back.
# * by {message_id, call_id} — for the order-race fix where
# `question.asked` may arrive before the matching tool part.
#
# The registry also exposes a `prompt_blocked?` predicate that
# Opencode::Client uses to suspend the SSE deadline check while
# a healthy wait is in progress.
class Prompts
Entry = Struct.new(:kind, :request, :asked_at, keyword_init: true)
def initialize
@entries = {}
@by_call = {}
end
def record_question(request)
record(:question, request)
end
def record_permission(request)
record(:permission, request)
end
# Returns the raw request hash (not the Entry wrapper) so callers
# don't depend on internal bookkeeping shape.
def find(request_id)
@entries[request_id]&.request
end
# Returns the raw request hash, same shape as #find.
def find_by_call(message_id:, call_id:)
key = call_key(message_id, call_id)
@by_call[key]&.request
end
def resolve(request_id)
entry = @entries.delete(request_id)
return unless entry
tool = entry.request[:tool]
return unless tool
@by_call.delete(call_key(tool[:messageID], tool[:callID]))
end
def each_pending
@entries.each_value { |entry| yield(entry.kind, entry.request) }
end
def any_pending?
@entries.any?
end
alias_method :prompt_blocked?, :any_pending?
def asked_at(request_id)
@entries[request_id]&.asked_at
end
private
def record(kind, request)
entry = Entry.new(
kind: kind,
request: request,
asked_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)
)
@entries[request[:id]] = entry
tool = request[:tool]
@by_call[call_key(tool[:messageID], tool[:callID])] = entry if tool
end
def call_key(message_id, call_id)
[ message_id, call_id ].join(":")
end
end
end

549
lib/opencode/reply.rb Normal file
View File

@@ -0,0 +1,549 @@
# frozen_string_literal: true
module Opencode
# An assistant's reply as it is being composed, live, from OpenCode SSE
# events. A Reply accumulates parts (text, reasoning, tool invocations)
# in the order the agent emits them and notifies observers of domain
# transitions — parts appearing, parts growing, tools advancing,
# sessions erroring.
#
# Responsibilities
# ----------------
#
# * Translate raw OpenCode SSE events into domain callbacks.
# * Own the canonical state of an in-flight reply (parts list, indices,
# first-token seen, message info).
# * Apply the tail-drop safety net: when part.updated carries
# authoritative :text that differs from what deltas accumulated
# (z.ai GLM-5.1 drops trailing deltas), rewrite the part's content.
# * Preserve the original tool name when OpenCode later renames a tool
# to "invalid" mid-stream.
#
# Not responsibilities
# --------------------
#
# * Rendering HTML or broadcasting Turbo Streams (observer concern).
# * Persisting parts to a database (observer concern).
# * Fetching the event stream (Opencode::Client).
# * Retry / session recovery (job concern).
#
# Event contract
# --------------
#
# Events match OpenCode's bus schema (packages/opencode/src/session/
# message-v2.ts, status.ts, todo.ts):
#
# message.part.delta { properties: { partID, field, delta, ... } }
# message.part.updated { properties: { part: { id, type, ... } } }
# message.updated { properties: { info: { tokens, cost, ... } } }
# session.status { properties: { status: { type, ... } } }
# session.error { properties: { error: { name, data, ... } } }
# todo.updated { properties: { todos: [...] } }
#
# Observer callbacks
# ------------------
#
# See Opencode::ReplyObserver for the full callback surface. Observers
# are duck-typed — only the callbacks they define are invoked.
#
# Example
# -------
#
# reply = Opencode::Reply.new
# reply.add_observer(MyApp::ReplyStream.new(message:)) # your observer
# client.stream_events(session_id: id) { |event| reply.apply(event) }
# reply.result
# # => Opencode::Reply::Result with parts_json, full_text, reasoning_text, tool_parts
#
class Reply
STREAMABLE_TYPES = %w[text reasoning tool].freeze
TERMINAL_TOOL_STATUSES = %w[completed error].freeze
TODO_TOOLS = %w[todowrite todoread].freeze
# The denormalized output of a Reply once streaming completes (or
# recovery via Reply.distill produces an equivalent shape). Symmetric
# with Opencode::Turn::Result. Accessible by both message-style
# (`result.full_text`) and hash-style (`result[:full_text]`) syntax
# — Struct supports both natively — but the typed shape stops
# callers from poking arbitrary keys.
Result = Struct.new(:parts_json, :full_text, :reasoning_text, :tool_parts, keyword_init: true)
attr_reader :parts, :info, :total_cost, :total_input_tokens, :total_output_tokens, :prompts
def initialize
@parts = []
@part_index_by_id = {}
@part_type_by_id = {}
@observers = []
@first_text_seen = false
@info = nil
@total_cost = 0.0
@total_input_tokens = 0
@total_output_tokens = 0
@todo_part_index = nil
@prompts = Opencode::Prompts.new
# Keyed by [message_id, call_id]: question.asked payloads that
# arrived before their matching tool part. Drained when the tool
# part shows up in apply_tool_state.
@pending_question_payloads = {}
end
# True while any interactive prompt (question or permission) is
# awaiting a user reply. Opencode::Client uses this to suspend the
# SSE inactivity deadline — a wait on the human is healthy, not a
# hang.
def prompt_blocked?
@prompts.prompt_blocked?
end
def add_observer(observer)
@observers << observer
self
end
# Drive the state machine forward with one SSE event. Unknown event
# types are ignored — OpenCode may add new events, and we shouldn't
# crash on them.
def apply(event)
case event[:type]
when "message.part.delta" then apply_part_delta(event)
when "message.part.updated" then apply_part_updated(event)
when "message.updated" then apply_message_updated(event)
when "session.status" then apply_session_status(event)
when "session.error" then apply_session_error(event)
when "todo.updated" then apply_todo_updated(event)
when "question.asked" then apply_question_asked(event)
when "question.replied" then apply_question_replied(event)
when "question.rejected" then apply_question_rejected(event)
when "permission.asked" then apply_permission_asked(event)
when "permission.replied" then apply_permission_replied(event)
end
end
# Treat `recovered_parts` as a clean-slate baseline: replace parts,
# clear the id→index map (recovered parts have no OpenCode part IDs),
# and reset the running cost/token totals plus the first-text flag.
#
# Why reset totals: step-finish events that produced the pre-crash
# totals are not in the recovery payload; keeping them would
# double-count when post-recovery step-finish events accumulate
# against the same counters.
#
# Used only by the recovery path — during normal streaming, parts
# accrete via apply_* helpers and totals flow through step-finish.
def replace_parts(recovered_parts)
@parts = recovered_parts
@part_index_by_id.clear
@part_type_by_id.clear
@total_cost = 0.0
@total_input_tokens = 0
@total_output_tokens = 0
@first_text_seen = false
end
# Bring the live reply up to a recovered/polled exchange snapshot and
# notify observers for new or changed parts. This is the streaming
# counterpart to replace_parts: when the SSE connection ends before
# OpenCode's multi-message tool loop has produced final text, Turn polls
# the message exchange. Those recovered parts still need to hit Turbo as
# incremental append/update events, not only the final row replacement.
def sync_recovered_parts(recovered_parts)
Array(recovered_parts).each_with_index do |part, index|
next if @parts[index] == part
part = deep_dup_part(part)
if index < @parts.length
@parts[index] = part
notify_recovered_part_updated(part, index)
else
@parts << part
notify(:part_added, part: part, index: index)
notify_recovered_part_updated(part, index)
end
@first_text_seen ||= part["type"] == "text" && part["content"].present?
end
end
# Record a part that originated OUTSIDE the OpenCode event stream —
# used when an observer synthesizes a part (e.g., a session error
# notice) that isn't a real message.part.* event but should still
# appear in the persisted parts_json. Returns the new index.
#
# Does NOT fire part_added — the injecting observer has already done
# whatever rendering it needed. Other observers can poll `parts` if
# they care about injected content.
def inject_part(part_hash)
@parts << part_hash
@parts.size - 1
end
def first_text_seen?
@first_text_seen
end
def tool_count
@parts.count { |p| p["type"] == "tool" }
end
# The denormalized result once streaming completes, matching the
# shape jobs persist to the message table: full_text for :content,
# reasoning_text for :reasoning, tool_parts for :tool_calls_json,
# and parts_json for :parts_json.
def result
self.class.distill(@parts)
end
# Pure function: given a parts array, return the denormalized result
# as an Opencode::Reply::Result value object. Exposed so a recovery
# path (fetch messages from the session API and map them through
# ResponseParser.extract_interleaved_parts) produces the same shape
# as live streaming.
def self.distill(parts)
Result.new(
parts_json: parts,
full_text: join_content(parts, "text"),
reasoning_text: join_content(parts, "reasoning"),
tool_parts: parts.select { |p| p["type"] == "tool" && TERMINAL_TOOL_STATUSES.include?(p["status"]) }
)
end
def self.join_content(parts, type)
parts.select { |p| p["type"] == type }.map { |p| p["content"].to_s }.join("\n\n")
end
private_class_method :join_content
private
def apply_part_delta(event)
field = event.dig(:properties, :field)
return unless %w[text reasoning].include?(field)
part_id = event.dig(:properties, :partID)
delta = event.dig(:properties, :delta).to_s
return if delta.empty?
index = @part_index_by_id[part_id]
if index.nil?
# Delta before part.updated. Pre-1.2 OpenCode streams occasionally
# emit in this order; downstream part.updated for this id will
# reconcile via reconcile_final_content.
type = @part_type_by_id[part_id] || (field == "reasoning" ? "reasoning" : "text")
index = append_part({ "type" => type, "content" => +"" }, part_id: part_id)
end
@parts[index]["content"] << delta
@first_text_seen ||= (field == "text" && @parts[index]["type"] == "text")
notify(:part_changed, part: @parts[index], index: index, delta: delta)
end
def apply_part_updated(event)
part = event.dig(:properties, :part) || {}
part_id = part[:id]
part_type = part[:type]
case part_type
when "step-finish"
cost = part[:cost].to_f
tokens = part[:tokens] || {}
@total_cost += cost
@total_input_tokens += tokens[:input].to_i
@total_output_tokens += tokens[:output].to_i
notify(:step_finished, cost: cost, tokens: tokens)
when "text", "reasoning"
@part_type_by_id[part_id] = part_type if part_id
if @part_index_by_id.key?(part_id)
reconcile_final_content(part_id, part)
elsif part[:text].present?
# Extreme tail-drop path: part.updated carries the full text
# but no deltas ever arrived. Materialize it as a one-shot part
# so the content isn't lost.
append_part({ "type" => part_type, "content" => part[:text].dup }, part_id: part_id)
end
when "tool"
register_tool(part_id, part) unless @part_index_by_id.key?(part_id)
apply_tool_state(part_id, part)
end
end
def apply_message_updated(event)
info = event.dig(:properties, :info)
return unless info.is_a?(Hash)
@info = info
notify(:message_updated, info: info)
end
def apply_session_status(event)
case event.dig(:properties, :status, :type)
when "retry"
notify(:session_retried,
attempt: event.dig(:properties, :status, :attempt),
message: event.dig(:properties, :status, :message).to_s)
end
end
def apply_session_error(event)
error = event.dig(:properties, :error) || {}
name = error[:name].to_s
message = error.dig(:data, :message).to_s
text = [ name, message ].reject(&:blank?).join(": ")
notify(:session_errored, text: text, raw: error)
end
# Close out a text/reasoning part: always fires :part_finalized so
# observers can flush any throttled broadcast, and rewrites content if
# part.updated carries an authoritative :text that diverges from the
# deltas we accumulated (tail-drop safety net for providers like
# z.ai GLM-5.1 that sometimes drop trailing deltas).
def reconcile_final_content(part_id, part)
index = @part_index_by_id[part_id]
final = part[:text]
return if final.blank?
@parts[index]["content"] = final.dup unless @parts[index]["content"] == final
notify(:part_finalized, part: @parts[index], index: index)
end
def register_tool(part_id, part)
append_part({
"type" => "tool",
"tool" => part[:tool],
"status" => part.dig(:state, :status)
}, part_id: part_id)
end
# Merge an incoming `message.part.updated` event state into the
# existing tool record. Delegates the field-by-field shape to
# Opencode::ToolPart so the streaming and recovery paths share one
# canonical definition of what a tool part looks like.
def apply_tool_state(part_id, part)
index = @part_index_by_id[part_id]
return unless index
record = @parts[index]
Opencode::ToolPart.merge_streaming_state(record, part)
@todo_part_index = index if todo_tool_part?(record)
notify(:tool_progressed,
part: record,
index: index,
status: record["status"],
raw: part)
drain_pending_question_payload(record)
end
def apply_todo_updated(event)
todos = event.dig(:properties, :todos) || []
notify(:todos_changed, todos: todos)
return unless todos.is_a?(Array)
canonical_todos = Opencode::Todo.canonicalize_all(todos)
index = current_todo_part_index
if index
refresh_existing_todo_part(index, canonical_todos, event)
else
@todo_part_index = append_part(Opencode::PartSource.stamp({
"type" => "tool",
"tool" => "todowrite",
"status" => "completed",
"input" => { "todos" => canonical_todos }
}, source: Opencode::PartSource::TODO_UPDATED))
end
end
# Refresh path for an existing todo part — either a real `todowrite`
# tool part materialized from message.part.updated, OR our own
# previously-stamped stream-only part. Either way we MERGE into
# `input` rather than replace it, so any non-todos fields a real
# tool call carried survive the refresh.
#
# We intentionally do NOT touch `part["title"]`. Upstream opencode's
# title is "N remaining todos" (a progress indicator like "2 todos"
# when 2 of 3 are still incomplete, "0 todos" when all done) and is
# set on the original message.part.updated event. Stomping it with
# our own value would clobber that semantic.
def refresh_existing_todo_part(index, canonical_todos, event)
part = @parts[index]
part["status"] = part["status"].presence || "completed"
part["input"] = (part["input"] || {}).merge("todos" => canonical_todos)
notify(:tool_progressed, part: part, index: index, status: part["status"], raw: event)
end
def current_todo_part_index
return @todo_part_index if @todo_part_index && todo_tool_part?(@parts[@todo_part_index])
@todo_part_index = @parts.rindex { |part| todo_tool_part?(part) }
end
def todo_tool_part?(part)
part.is_a?(Hash) && part["type"] == "tool" && TODO_TOOLS.include?(part["tool"].to_s)
end
def deep_dup_part(part)
case part
when Hash
part.transform_values { |value| deep_dup_part(value) }
when Array
part.map { |value| deep_dup_part(value) }
else
part.duplicable? ? part.dup : part
end
end
def notify_recovered_part_updated(part, index)
case part["type"]
when "tool"
notify(:tool_progressed, part: part, index: index, status: part["status"], raw: {})
when "text", "reasoning"
notify(:part_finalized, part: part, index: index)
end
end
def append_part(part_hash, part_id: nil)
@parts << part_hash
index = @parts.size - 1
if part_id
@part_index_by_id[part_id] = index
@part_type_by_id[part_id] = part_hash["type"]
end
notify(:part_added, part: @parts[index], index: index)
index
end
def notify(callback, **payload)
@observers.each do |observer|
observer.public_send(callback, **payload) if observer.respond_to?(callback)
end
end
# --- interactive prompts -----------------------------------------
def apply_question_asked(event)
request = (event[:properties] || {}).dup
return unless request[:id].is_a?(String)
@prompts.record_question(request)
if (tool = request[:tool])
@pending_question_payloads[[ tool[:messageID].to_s, tool[:callID].to_s ]] = request
end
merge_pending_question_into_existing_tool_part(request)
notify(:question_asked, request: request, raw: event)
end
def apply_question_replied(event)
props = event[:properties] || {}
request_id = props[:requestID]
answers = props[:answers] || []
return unless request_id
asked_at = @prompts.asked_at(request_id)
@prompts.resolve(request_id)
notify(:question_replied, request_id: request_id, answers: answers, raw: event, asked_at: asked_at)
end
def apply_question_rejected(event)
props = event[:properties] || {}
request_id = props[:requestID]
return unless request_id
asked_at = @prompts.asked_at(request_id)
@prompts.resolve(request_id)
notify(:question_rejected, request_id: request_id, raw: event, asked_at: asked_at)
end
def apply_permission_asked(event)
request = (event[:properties] || {}).dup
return unless request[:id].is_a?(String)
@prompts.record_permission(request)
notify(:permission_asked, request: request, raw: event)
end
def apply_permission_replied(event)
props = event[:properties] || {}
request_id = props[:requestID]
return unless request_id
asked_at = @prompts.asked_at(request_id)
@prompts.resolve(request_id)
notify(:permission_replied,
request_id: request_id,
reply: props[:reply],
raw: event,
asked_at: asked_at)
end
# Merge a pending question payload into the matching tool part if
# the tool part exists. Reads record["callID"] / record["messageID"]
# which are persisted by ToolPart.merge_streaming_state (per Task 2.0).
# Decorates the part's "input" with both the question content AND the
# opencode identifiers the view + controller need.
#
# Called from two paths:
# 1. apply_question_asked, when the tool part already exists
# 2. apply_tool_state, when the tool part arrives AFTER question.asked
def merge_pending_question_into_existing_tool_part(request)
tool = request[:tool]
return unless tool
call_id = tool[:callID].to_s
message_id = tool[:messageID].to_s
return if call_id.empty?
index = @parts.index do |part|
part.is_a?(Hash) && part["type"] == "tool" && part["tool"] == "question" &&
part["callID"] == call_id
end
return unless index
part = @parts[index]
# Stringify keys so the in-memory shape matches what's persisted
# via the parts_json JSON column round-trip. Otherwise direct-render
# callers (e.g., integration tests, future debug tooling) hit
# symbol-keyed nested hashes while the partials read string keys —
# silent broken HTML.
input = (part["input"] || {}).merge(
"questions" => deep_stringify_keys(request[:questions]),
"opencode_request_id" => request[:id],
"opencode_message_id" => message_id,
"opencode_call_id" => call_id
)
part["input"] = input
notify(:tool_progressed, part: part, index: index, status: part["status"],
raw: { type: "question.asked.synthesized" })
end
# Order-race fix: if question.asked arrived before this tool part,
# its payload is parked in @pending_question_payloads keyed by
# {messageID, callID}. Drain it now so the part's input carries
# the questions + opencode_* identifiers the view expects.
def drain_pending_question_payload(record)
return unless record["tool"] == "question" && record["callID"].present?
key = [ record["messageID"].to_s, record["callID"].to_s ]
pending = @pending_question_payloads.delete(key)
merge_pending_question_into_existing_tool_part(pending) if pending
end
# Recursively converts hash keys to strings — used at the SSE/JSON
# boundary so in-memory parts match the shape they have after a
# parts_json (JSON column) round-trip. Same semantics as Rails'
# Hash#deep_stringify_keys but iterates arrays too.
def deep_stringify_keys(obj)
case obj
when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
when Array then obj.map { |x| deep_stringify_keys(x) }
else obj
end
end
end
end

View File

@@ -0,0 +1,101 @@
# frozen_string_literal: true
module Opencode
# The canonical observer protocol for Opencode::Reply — every event
# Reply dispatches, documented in one place, with safe no-op defaults.
#
# Include this module in a reply-stream class to get two things:
#
# 1. **Compile-time checklist.** Override only the callbacks you care
# about; the rest inherit a no-op. Forgetting to handle a new event
# never crashes the stream.
# 2. **Protocol documentation that can't rot.** The signatures here are
# the contract. If Reply's dispatch shape ever drifts, every observer
# using this module updates in lockstep.
#
# Callbacks are duck-typed in Reply — features may choose not to
# include this module and implement the methods directly, but then
# they lose the two benefits above.
#
# Every callback takes keyword arguments, so adding a new keyword later
# only requires existing observers to add `**_` if they want to opt out
# of breakage.
module ReplyObserver
# A new part was appended to the reply's parts list.
def part_added(part:, index:)
end
# An existing part's content grew by a delta (streaming text or
# reasoning).
def part_changed(part:, index:, delta:)
end
# An existing part's content was rewritten to the authoritative
# value from part.updated. Fires unconditionally when a part closes
# so throttled observers can flush, regardless of whether content
# actually diverged from what deltas accumulated.
def part_finalized(part:, index:)
end
# A tool part transitioned status (pending → running → completed/error),
# or its state payload (title/input/error) changed.
def tool_progressed(part:, index:, status:, raw:)
end
# A step boundary with usage info. `tokens` is the raw tokens hash
# from the step-finish part (keys: :input, :output, :reasoning, :cache).
def step_finished(cost:, tokens:)
end
# The upstream session is retrying an LLM call (e.g., provider
# rate-limit backoff). Attempt is nullable; message is a short
# reason string.
def session_retried(attempt:, message:)
end
# A session-level error surfaced. Text is a human-readable summary
# ("ErrorName: details"); raw is the full error hash.
def session_errored(text:, raw:)
end
# The authoritative message.info was updated (cost, tokens, provider
# error metadata). Fires late in the stream after the agent closes.
def message_updated(info:)
end
# Agent's internal todo list changed. Todos are whatever shape the
# agent's task tool uses.
def todos_changed(todos:)
end
# opencode emitted a question.asked event — the agent's `question`
# tool is suspended waiting for the user's reply. `request` is the
# full QuestionRequest hash ({id, sessionID, questions, tool?}).
def question_asked(request:, raw:)
end
# opencode emitted a question.replied event — the user submitted
# answers (Array<Array<String>>, one inner array per question).
# `asked_at` is the monotonic clock value when question.asked was
# observed, for latency telemetry; nil if asked never arrived.
def question_replied(request_id:, answers:, raw:, asked_at:)
end
# opencode emitted a question.rejected event — the user dismissed
# the prompt, or it was cancelled (e.g., container shutdown).
def question_rejected(request_id:, raw:, asked_at:)
end
# opencode emitted a permission.asked event — a tool is requesting
# user permission to proceed. `request` is the PermissionRequest
# hash ({id, sessionID, permission, patterns, metadata, always, tool?}).
def permission_asked(request:, raw:)
end
# opencode emitted a permission.replied event — the user chose
# once/always/reject. `reply` is the string. `asked_at` per
# question_replied semantics.
def permission_replied(request_id:, reply:, raw:, asked_at:)
end
end
end

View File

@@ -0,0 +1,169 @@
# frozen_string_literal: true
module Opencode
module ResponseParser
def self.extract_text(response_body)
parts = response_body[:parts] || []
parts
.select { |p| p[:type] == "text" }
.map { |p| p[:text] }
.join("\n\n")
end
def self.extract_reasoning(response_body)
parts = response_body[:parts] || []
reasoning = parts
.select { |p| p[:type] == "reasoning" }
.map { |p| p[:text] }
.join("\n\n")
reasoning.presence
end
TERMINAL_STATUSES = %w[completed error].freeze
# Terminal-only tool list. Returned as canonical string-keyed hashes
# (same shape `extract_interleaved_parts` returns) so callers do not
# have to know which path produced the data.
def self.extract_tool_summary(response_body)
parts = response_body[:parts] || []
parts
.select { |p| p[:type] == "tool" && p.dig(:state, :status).in?(TERMINAL_STATUSES) }
.map { |p| build_tool_summary(p) }
end
def self.extract_interleaved_parts(response_body)
parts = response_body[:parts] || []
parts.filter_map do |part|
case part[:type]
when "text"
{ "type" => "text", "content" => part[:text] }
when "reasoning"
{ "type" => "reasoning", "content" => part[:text] }
when "tool"
status = part.dig(:state, :status)
next unless status.in?(TERMINAL_STATUSES)
build_tool_summary(part)
else
nil
end
end
end
# Canonical tool-part shape from one OpenCode message part. Delegates
# to Opencode::ToolPart so the streaming path (Reply#apply_tool_state)
# and recovery path (this method) cannot drift.
def self.build_tool_summary(part)
Opencode::ToolPart.from_message_part(part)
end
private_class_method :build_tool_summary
def self.extract_tokens(response_body)
response_body.dig(:info, :tokens)
end
def self.extract_cost(response_body)
response_body.dig(:info, :cost)
end
def self.extract_cache_tokens(response_body)
tokens = response_body.dig(:info, :tokens) || {}
{
cache_read: tokens.dig(:cache, :read) || 0,
cache_write: tokens.dig(:cache, :write) || 0
}
end
def self.extract_error(response_body)
error = response_body.dig(:info, :error)
return nil unless error.is_a?(Hash)
{
name: error[:name],
message: error.dig(:data, :message),
status_code: error.dig(:data, :statusCode),
retryable: error.dig(:data, :isRetryable),
url: error.dig(:data, :metadata, :url)
}.compact
end
MAX_ARTIFACT_SIZE = 10.megabytes
ARTIFACT_TOOLS = %w[write apply_patch].freeze
def self.extract_artifact_files(response_body)
parts = response_body[:parts] || []
completed_tools = parts.select do |p|
p[:type] == "tool" &&
ARTIFACT_TOOLS.include?(p[:tool]) &&
p.dig(:state, :status) == "completed"
end
return [] if completed_tools.empty?
files = completed_tools.flat_map { |part| extract_files_from_tool_part(part) }
files.uniq { |f| f[:filename] }
end
def self.extract_artifacts_from_messages(messages)
return [] unless messages.is_a?(Array)
messages
.select { |m| m.dig(:info, :role) == "assistant" }
.flat_map { |m| extract_artifact_files(m) }
.uniq { |f| f[:filename] }
end
def self.extract_files_from_tool_part(part)
case part[:tool]
when "write"
extract_from_write(part)
when "apply_patch"
extract_from_apply_patch(part)
else
[]
end
end
def self.extract_from_write(part)
content = part.dig(:state, :input, :content)
file_path = part.dig(:state, :input, :filePath)
return [] if content.blank? || file_path.blank?
return [] if content.bytesize > MAX_ARTIFACT_SIZE
filename = File.basename(file_path)
content_type = Marcel::MimeType.for(extension: File.extname(filename))
[ { filename: filename, content: content, content_type: content_type } ]
end
# apply_patch tool metadata shape changed materially between the early
# opencode versions this code originally targeted (which exposed
# `before` + `after` post-write file content as inline strings) and
# v1.4.0+ (which dropped them and only exposes the diff text in `patch`
# plus a `files` array of { filePath, relativePath, type, patch,
# additions, deletions, movePath? } descriptors). Source of truth:
# https://raw.githubusercontent.com/anomalyco/opencode/v1.15.0/packages/opencode/src/tool/apply_patch.ts
#
# With no `after` field in the v1.15.0 wire shape, this method previously
# silently returned [] for every real apply_patch invocation while still
# passing its (now-stale-shape) unit test — the worst kind of bug: a
# green test paired with a dead production path.
#
# Current behavior (intentional, until apply_patch becomes a hot path
# for the gem's users): we accept the v1.15.0 shape and return []. Most
# agents write whole files via the `write` tool rather than patching,
# so the practical impact today is zero. When you do use apply_patch,
# opencode-rails' `Opencode::Exchange#tool_artifacts` emits
# `opencode.apply_patch.artifacts_dropped` so operators see the silent
# drop and can route through the missing sandbox-read path.
#
# The event emission lives on Exchange (not here) because ResponseParser
# is a pure module — every other method takes a hash and returns a hash.
# Pure functions stay pure.
def self.extract_from_apply_patch(_part)
[]
end
private_class_method :extract_files_from_tool_part, :extract_from_write, :extract_from_apply_patch
end
end

43
lib/opencode/todo.rb Normal file
View File

@@ -0,0 +1,43 @@
# frozen_string_literal: true
module Opencode
# One todo item the OpenCode `todowrite` tool and `todo.updated` bus
# event carry: `content` + `status` + (optional) `priority`.
# Source-of-truth canonicalization lives here so Reply, ToolDisplay,
# and any future consumer all share one definition of "what does this
# todo look like once we've normalized it."
#
# Status canonicalization: OpenCode bus events have been observed
# emitting the hyphenated `"in-progress"` form. The rest of the
# codebase (per-product views, todowrite tool input shape per the
# v1.15+ openapi spec) uses the underscored `"in_progress"`.
# Canonicalize to underscore at every entry point so downstream code
# never has to handle both.
module Todo
HYPHENATED_TO_CANONICAL_STATUS = {
"in-progress" => "in_progress"
}.freeze
module_function
def canonical_status(status)
raw = status.to_s
HYPHENATED_TO_CANONICAL_STATUS.fetch(raw) { raw.tr("-", "_") }
end
# Canonicalize one todo hash: string-keyed, normalized status.
# Returns the input unchanged when it isn't a Hash (the substrate
# tolerates wire-shape drift defensively).
def canonicalize(todo)
return todo unless todo.is_a?(Hash)
result = todo.deep_stringify_keys
result["status"] = canonical_status(result["status"]) if result.key?("status")
result
end
def canonicalize_all(todos)
Array(todos).map { |t| canonicalize(t) }
end
end
end

152
lib/opencode/tool_part.rb Normal file
View File

@@ -0,0 +1,152 @@
# frozen_string_literal: true
module Opencode
# Canonical shape of a tool part in an assistant reply.
#
# A tool part starts `pending` and transitions through `running` to a
# terminal `completed` or `error`. The complete representation carries
# seven fields, all string-keyed so views read consistent keys whether
# the part came from a live streaming event or a post-stream message
# poll:
#
# "type" => "tool"
# "tool" => "edit"
# "status" => "completed"
# "title" => "Edited /INDEX.md"
# "input" => { ... } # full args the agent passed, deep-stringified
# "metadata" => { ... } # tool-specific output: diff, preview, stdout, etc.
# "output" => "Edited successfully."
# "error" => "..." # only when status == "error", truncated to 200 chars
#
# The shape is produced two ways:
#
# 1. Opencode::Reply#apply_tool_state — live, mid-stream, merging
# incoming event state into an in-memory record (previous values
# survive when the new event omits a field).
#
# 2. Opencode::ResponseParser.build_tool_summary — post-stream, built
# fresh from a complete OpenCode message returned by
# /session/:id/message during recovery / final-exchange polling.
#
# Existence reason: the two paths used to drift. ResponseParser stripped
# `metadata` and whitelisted `input` to a fixed key list, so `parts_json`
# saved on finalize had strictly less data than the streaming DOM had
# shown. The visible symptom was "I saw the diff while streaming and it
# disappeared when the turn finished". This class is the single source of
# truth that prevents that drift.
module ToolPart
MAX_ERROR_LEN = 200
INVALID_TOOL = "invalid"
module_function
# Build a fresh canonical tool-part hash from one OpenCode message
# part (the shape that arrives through /session/:id/message).
# Used by ResponseParser for recovery and final-exchange polling.
def from_message_part(part)
state = state_of(part)
build_canonical(
tool: part[:tool] || part["tool"],
status: state_value(state, :status),
title: state_value(state, :title),
input: state_value(state, :input),
metadata: state_value(state, :metadata),
output: state_value(state, :output),
error: state_value(state, :error)
)
end
# Merge an incoming `message.part.updated` event state into an
# existing record. Used by Reply#apply_tool_state during streaming.
#
# Fields the event omits (or that arrive empty) leave the record's
# previous value intact. Mid-tool events are partial by design.
#
# In addition to the canonical render fields (status, title, input,
# metadata, output, error), this also persists `callID` and
# `messageID` from the incoming state. Those identifiers are needed
# by downstream lookups (e.g. matching an ask-user reply event back
# to the originating tool part by callID) and would otherwise be
# silently dropped on the way into Reply.parts JSON.
#
# Returns the (mutated) record for chaining.
def merge_streaming_state(record, part)
state = state_of(part)
tool = part[:tool] || part["tool"]
# Preserve original tool name if OpenCode later renames to "invalid"
# mid-session — we want to keep rendering the original name.
record["tool"] = tool if tool.present? && tool != INVALID_TOOL
status = state_value(state, :status)
record["status"] = status if status
title = state_value(state, :title)
record["title"] = title if title.present?
input = state_value(state, :input)
record["input"] = stringify_deep(input) if input.present?
metadata = state_value(state, :metadata)
record["metadata"] = stringify_deep(metadata) if metadata.present?
output = state_value(state, :output)
record["output"] = output if output.present?
error = state_value(state, :error)
record["error"] = error.to_s.truncate(MAX_ERROR_LEN) if error.present?
# callID and messageID moved from state.* to the part's top level
# somewhere in opencode v1.15.x. Read top-level first, fall back
# to state.* for any older versions that may still be in flight.
# Without this, merge_pending_question_into_existing_tool_part
# (which searches @parts by callID) silently no-ops, and the
# question form renders with no questions or routing IDs.
call_id = part[:callID] || part["callID"] || state_value(state, :callID)
record["callID"] = call_id if call_id.present?
message_id = part[:messageID] || part["messageID"] || state_value(state, :messageID)
record["messageID"] = message_id if message_id.present?
record
end
class << self
private
def state_of(part)
part[:state] || part["state"] || {}
end
def state_value(state, key)
return nil unless state.is_a?(Hash)
state[key] || state[key.to_s]
end
def build_canonical(tool:, status:, title:, input:, metadata:, output:, error:)
hash = {
"type" => "tool",
"tool" => tool.to_s.presence,
"status" => status,
"title" => title.presence,
"input" => stringify_deep(input).presence,
"metadata" => stringify_deep(metadata).presence,
"output" => output.presence
}
hash["error"] = error.to_s.truncate(MAX_ERROR_LEN).presence if status == "error"
hash.compact
end
def stringify_deep(value)
case value
when Hash
value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_deep(v) }
when Array
value.map { |v| stringify_deep(v) }
else
value
end
end
end
end
end

50
lib/opencode/tracer.rb Normal file
View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
module Opencode
# A namespacing trace emitter.
#
# Opencode::Turn emits unprefixed event names like "response.started"
# and "session.recreated". The host product wraps Turn in a Tracer
# whose job is to prepend a product prefix and forward to whatever
# actually emits trace events (typically the host job's
# `EventTraceable#trace_event`).
#
# Two responsibilities live here, and only here:
#
# 1. Callable interface: `tracer.call(name, **payload)` — the
# contract Turn relies on.
# 2. Namespacing strategy: prepend "<prefix>." to every event name.
#
# A closure-based alternative that mixes both concerns looks like:
#
# tracer: ->(name, **payload) { trace_event("myapp.#{name}", **payload) }
#
# That closure conflates the two responsibilities; every caller has
# to rediscover the prefix-with-period rule, and a typo only shows up
# in production trace data. Making it a real role removes that risk
# and makes the rule visible in one place.
#
# Usage:
#
# Opencode::Tracer.new(prefix: "myapp", emitter: self)
#
# `emitter` must respond to `trace_event(name, **payload)`.
class Tracer
def initialize(prefix:, emitter:)
@prefix = prefix
@emitter = emitter
end
# Tracer is callable so existing call sites that treated the tracer
# as a lambda (`tracer.call(name, **payload)`) keep working without
# change. Turn uses this exclusively.
#
# Uses `send` because EventTraceable's `trace_event` is a private
# method of the including class — the convention is "private inside
# the job, but the substrate's Tracer is allowed to dispatch to it
# the same way the job's own perform method would."
def call(name, **payload)
@emitter.send(:trace_event, "#{@prefix}.#{name}", **payload)
end
end
end

5
lib/opencode/version.rb Normal file
View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Opencode
VERSION = "0.0.1.alpha2"
end

45
opencode-ruby.gemspec Normal file
View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
require_relative "lib/opencode/version"
Gem::Specification.new do |spec|
spec.name = "opencode-ruby"
spec.version = Opencode::VERSION
spec.authors = ["Ajay Krishnan"]
spec.email = ["opencode-ruby@ajay.to"]
spec.summary = "Idiomatic Ruby client for OpenCode (HTTP + SSE)."
spec.description = <<~DESC
Hand-rolled, opinionated Ruby SDK for OpenCode's REST + SSE API.
Block-form streaming, value-object responses, automatic SSE
reconnection. Complement to opencode_client (auto-generated from
OpenAPI) — pick this one if you want a small Ruby-idiomatic surface;
pick opencode_client if you want every endpoint with generated types.
DESC
spec.homepage = "https://github.com/ajaynomics/opencode-ruby"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
spec.files = Dir.glob("lib/**/*.rb") +
Dir.glob("examples/**/*.rb") +
%w[README.md LICENSE CHANGELOG.md opencode-ruby.gemspec]
spec.require_paths = ["lib"]
# The only runtime dependency is ActiveSupport (NOT Rails). ActiveSupport
# is a standalone gem providing the `present?`/`blank?`/`presence`/
# `truncate`/`duplicable?` helpers used in this gem's code. It does NOT
# pull in ActiveRecord, ActionView, ActionController, Turbo, or any other
# Rails-only piece. Most Ruby apps in the wild already have ActiveSupport
# transitively via another gem; in the rare case yours doesn't, ~250 LOC
# of core_ext is added when this gem installs.
spec.add_runtime_dependency "activesupport", ">= 6.1", "< 9.0"
spec.add_development_dependency "minitest", "~> 5.20"
spec.add_development_dependency "rake", "~> 13.0"
spec.add_development_dependency "webmock", "~> 3.20"
end

208
test/opencode/smoke_test.rb Normal file
View File

@@ -0,0 +1,208 @@
# frozen_string_literal: true
require "test_helper"
# End-to-end smoke test of the gem's public surface. Validates that the
# headline `client.stream(...)` API + Reply::Result + error model + the
# pluggable Instrumentation adapter all work against a fully mocked
# OpenCode server. This is the test we'd point at in the README to
# prove the postcard works.
class SmokeTest < Minitest::Test
BASE = "http://opencode.test"
PASSWORD = "test-secret"
SESSION_ID = "ses_smoke_1"
def setup
@client = Opencode::Client.new(
base_url: BASE,
password: PASSWORD,
timeout: 5
)
Opencode::Instrumentation.adapter = nil
end
def test_VERSION_is_a_string
assert_kind_of String, Opencode::VERSION
assert_match(/\A\d+\.\d+\.\d+/, Opencode::VERSION)
end
def test_constants_are_loaded
assert_equal "Opencode::Client", Opencode::Client.name
assert_equal "Opencode::Reply", Opencode::Reply.name
assert_equal "Opencode::Reply::Result", Opencode::Reply::Result.name
assert_equal "Opencode::Error", Opencode::Error.name
assert Opencode::ConnectionError < Opencode::Error
end
def test_health_endpoint_round_trip
stub_request(:get, "#{BASE}/global/health")
.to_return(status: 200, body: { healthy: true, version: "1.15.5" }.to_json,
headers: { "Content-Type" => "application/json" })
response = @client.health
assert_equal true, response[:healthy]
assert_equal "1.15.5", response[:version]
end
def test_create_session_returns_session_id
stub_request(:post, "#{BASE}/session")
.to_return(status: 200, body: { id: SESSION_ID, title: "smoke" }.to_json,
headers: { "Content-Type" => "application/json" })
response = @client.create_session(title: "smoke", permissions: [])
assert_equal SESSION_ID, response[:id]
end
def test_send_message_async_returns_empty_body
stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async")
.to_return(status: 204, body: "")
response = @client.send_message_async(SESSION_ID, "ping")
assert_equal({}, response)
end
def test_stream_returns_typed_Reply_Result_with_full_text
stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async")
.to_return(status: 204, body: "")
sse = [
{ type: "message.part.delta",
properties: { sessionID: SESSION_ID, partID: "p1", field: "text", delta: "hello " } },
{ type: "message.part.delta",
properties: { sessionID: SESSION_ID, partID: "p1", field: "text", delta: "world" } },
{ type: "session.idle", properties: { sessionID: SESSION_ID } }
].map { |e| "data: #{e.to_json}\n\n" }.join
stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(status: 200, body: sse,
headers: { "Content-Type" => "text/event-stream" })
stub_request(:get, "#{BASE}/session/#{SESSION_ID}/message")
.to_return(status: 200, body: [].to_json,
headers: { "Content-Type" => "application/json" })
parts_yielded = []
reply = @client.stream(SESSION_ID, "ping") do |part|
parts_yielded << part.dup
end
assert_kind_of Opencode::Reply::Result, reply
assert_equal "hello world", reply.full_text
# Struct value object supports both message and hash style access.
assert_equal "hello world", reply[:full_text]
refute_empty parts_yielded
end
def test_stream_block_is_optional
stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async")
.to_return(status: 204, body: "")
sse = [
{ type: "message.part.delta",
properties: { sessionID: SESSION_ID, partID: "p1", field: "text", delta: "ack" } },
{ type: "session.idle", properties: { sessionID: SESSION_ID } }
].map { |e| "data: #{e.to_json}\n\n" }.join
stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(status: 200, body: sse,
headers: { "Content-Type" => "text/event-stream" })
stub_request(:get, "#{BASE}/session/#{SESSION_ID}/message")
.to_return(status: 200, body: [].to_json,
headers: { "Content-Type" => "application/json" })
reply = @client.stream(SESSION_ID, "ping")
assert_equal "ack", reply.full_text
end
def test_connection_refused_raises_ConnectionError
stub_request(:get, "http://opencode.dead/global/health")
.to_raise(Errno::ECONNREFUSED)
bad = Opencode::Client.new(base_url: "http://opencode.dead", timeout: 1)
assert_raises(Opencode::ConnectionError) { bad.health }
end
def test_404_on_session_endpoint_raises_SessionNotFoundError
stub_request(:get, "#{BASE}/session/missing/message")
.to_return(status: 404, body: { error: "not found" }.to_json,
headers: { "Content-Type" => "application/json" })
assert_raises(Opencode::SessionNotFoundError) do
@client.get_messages("missing")
end
end
def test_instrumentation_adapter_receives_request_events
events = []
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
events << [ name, payload ]
block.call
}
stub_request(:get, "#{BASE}/global/health")
.to_return(status: 200, body: "{}",
headers: { "Content-Type" => "application/json" })
@client.health
assert events.any? { |name, _| name == "opencode.request" },
"instrumentation adapter must receive opencode.request events"
end
def test_Reply_distill_returns_typed_Result
parts = [
{ "type" => "text", "content" => "hi" },
{ "type" => "text", "content" => "there" },
{ "type" => "tool", "tool" => "read", "status" => "completed" }
]
result = Opencode::Reply.distill(parts)
assert_kind_of Opencode::Reply::Result, result
assert_equal "hi\n\nthere", result.full_text
assert_equal 1, result.tool_parts.size
end
def test_Instrumentation_no_op_default_yields_block_value
Opencode::Instrumentation.adapter = nil
assert_equal 42, Opencode::Instrumentation.instrument("x") { 42 }
end
def test_Instrumentation_notify_no_op_without_adapter
Opencode::Instrumentation.adapter = nil
# Must not raise; must return nil.
assert_nil Opencode::Instrumentation.notify("x", foo: 1)
end
def test_Instrumentation_notify_forwards_to_adapter_fire_and_forget
events = []
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
# block_given? is misleading inside a lambda — check the captured
# &block instead. AS::Notifications-shaped adapters always
# expect a block (it's what marks "event finished").
events << [ name, payload, !block.nil? ]
block.call if block
:adapter_return_ignored
}
result = Opencode::Instrumentation.notify("opencode.session.recreated", session_id: "ses_1")
# notify is fire-and-forget — it returns nil, NOT the adapter's
# return value (that's what .instrument does).
assert_nil result
assert_equal 1, events.size
name, payload, had_block = events.first
assert_equal "opencode.session.recreated", name
assert_equal({ session_id: "ses_1" }, payload)
assert had_block,
"notify must still pass an empty block — AS::Notifications-shaped " \
"adapters always expect one"
end
def test_Instrumentation_notify_does_not_require_block
Opencode::Instrumentation.adapter = ->(_name, _payload, &_block) { }
# Call site has no block — that's the whole point of notify.
Opencode::Instrumentation.notify("opencode.test", k: "v")
# If we got here without raising, the API is fire-and-forget as designed.
assert true
end
end

10
test/test_helper.rb Normal file
View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "opencode-ruby"
require "minitest/autorun"
require "webmock/minitest"
# Tests run against WebMock-stubbed endpoints; never hit the network.
WebMock.disable_net_connect!