Initial opencode-ruby v0.0.1.alpha1 — hand-rolled HTTP+SSE client for OpenCode
Headline API:
reply = client.stream(session_id, "Explain monads") do |part|
print part["content"] if part["type"] == "text"
end
reply.full_text # final accumulated text
Sources ported from ajaynomics/ajent-rails lib/opencode/client/ after
the Phase-1+2 tier carve + Phase-2.5 boundary cleanup (see ajent-rails
PRs #840 and #843). Rails-runtime coupling stripped:
- Defaults read from ENV[OPENCODE_BASE_URL/SERVER_PASSWORD/TIMEOUT]
instead of Rails.application.config.x.opencode_blackline.*
- EventTraceable.timed_event(...) calls swapped for
Opencode::Instrumentation.instrument(...) — pluggable adapter
(default no-op) that callers wire to ActiveSupport::Notifications,
OpenTelemetry, stdout, etc.
Runtime dependency: activesupport (>= 6.1, < 9.0) for the small
core_ext surface (blank?/present?/presence/truncate/duplicable?/
megabytes). ActiveSupport is NOT Rails — it's a standalone helpers
gem that most Ruby apps already have transitively.
What's in the gem:
Opencode::Client HTTP + SSE client; #stream block-form API
Opencode::Reply SSE-event accumulator with observer protocol
Opencode::Reply::Result typed Struct value object
Opencode::ReplyObserver observer protocol module (no-op defaults)
Opencode::Prompts per-Reply pending question/permission registry
Opencode::Tracer callable that prefixes event names
Opencode::Instrumentation pluggable adapter
Opencode::ResponseParser wire-format extractors
Opencode::ToolPart canonical tool-part hash shape
Opencode::PartSource wire-vs-stream-only discriminator
Opencode::Todo todo status canonicalization
Opencode::Error (+ 7 subclasses)
What's out (per design D18 — wait for demand signal):
- acts_as_opencode_session concern
- ActiveRecord-backed session lifecycle
- rails generators
- opencode-rails as a separate gem
Instead, examples/conversation_recipe.rb ships as a ~140-line
plain-ActiveRecord blueprint demonstrating session lifecycle,
with_lock, update_columns mid-stream pattern, and CAS-safe finalize.
Tests: 12 runs, 25 assertions, 0 failures (smoke test against
WebMock-stubbed OpenCode endpoints — covers the postcard, error
model, Instrumentation, and Reply::Result shape).
Authored against ajent-rails commit 02954eeb (opencode-gem/phase-3-prep).
This commit is contained in:
170
lib/opencode/response_parser.rb
Normal file
170
lib/opencode/response_parser.rb
Normal file
@@ -0,0 +1,170 @@
|
||||
# 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):
|
||||
# we accept the v1.15.0 shape and return []. None of the active agents
|
||||
# (travel-agent, employment-lawyer, contract-lawyer, raven-legal,
|
||||
# better-*) use apply_patch — they write whole files via the `write`
|
||||
# tool — so the practical-impact-today is zero. When the first
|
||||
# apply_patch-using agent ships, Opencode::Exchange#tool_artifacts
|
||||
# emits `opencode.apply_patch.artifacts_dropped` so operators see the
|
||||
# silent drop and 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
|
||||
Reference in New Issue
Block a user