Files
opencode-ruby/lib/opencode/response_parser.rb
Ajay Krishnan 889d38332f
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
Initial public release v0.0.1.alpha2
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.
2026-05-20 21:41:30 -07:00

170 lines
5.7 KiB
Ruby

# 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