Files
opencode-rails/lib/opencode/exchange.rb
Ajay Krishnan 69cfff55b2 Port lib/opencode/rails/ source files; strip Rails.event/Rails.error
Eleven source files moved from ajent-rails:lib/opencode/rails/ to
opencode-rails:lib/opencode/ (flat layout — modules are Opencode::*, not
Opencode::Rails::*; matches opencode-ruby).

  artifact.rb           63 LOC
  exchange.rb           77 LOC
  impostor.rb           48 LOC
  message_artifacts.rb 133 LOC
  sandbox_file.rb       81 LOC
  sandbox.rb            71 LOC
  session.rb           168 LOC
  tool_display.rb      423 LOC
  transform.rb          77 LOC
  turn.rb              642 LOC
  uploaded_files_prompt.rb 85 LOC
  ----
  total              1,868 LOC

Surgical Rails strips:

  exchange.rb:
    Rails.event.notify(name, payload)
      -> Opencode::Instrumentation.instrument(name, payload) { }

  message_artifacts.rb (1 call), turn.rb (6 calls):
    Rails.error.report(error, **opts)
      -> Opencode::ErrorReporter.report(error, **opts)

Comments/docstrings referencing Rails.error.report / Rails.event left
in place — they document how to wire the host adapter.

ActiveSupport core_ext requires expanded in lib/opencode-rails.rb to
cover Numeric#seconds, Hash#deep_stringify_keys, String#squish/truncate,
String#demodulize. Bundle install + smoke load confirms all 12
gem-provided constants resolve cleanly.
2026-05-20 05:14:00 -07:00

80 lines
3.2 KiB
Ruby

# frozen_string_literal: true
module Opencode
# The OpenCode messages produced by a single turn (the array returned
# by GET /session/:id/message and consumed by Opencode::Turn's
# recovery + finalization paths).
#
# First-class noun rather than a bare array because:
#
# - It owns the "give me the tool-produced artifacts" question, so
# callers don't reach into ResponseParser for that. The parser is
# about wire-shape extraction; the Exchange is about the domain
# concept of "what files came out of this turn."
#
# - It owns the "opencode.apply_patch.artifacts_dropped" event
# emission, keeping ResponseParser a pure module (no instrumentation
# side effects). Pure functions stay pure. The event flows through
# Opencode::Instrumentation, so hosts wire AS::Notifications /
# Rails.event / OpenTelemetry / etc. via the adapter.
class Exchange
def initialize(messages)
@messages = Array(messages)
end
# Returns Opencode::Artifact values for every file produced by a
# tool call in this exchange (currently the `write` tool; apply_patch
# is acknowledged-but-empty in v1.15+, see ResponseParser).
#
# `exclude:` filters by destination filename — used by the substrate
# to keep tool-extracted Artifacts from racing per-message transforms
# that own the same filenames.
def tool_artifacts(exclude: [])
excluded = Set.new(exclude)
raw = Opencode::ResponseParser.extract_artifacts_from_messages(@messages)
notify_drops(raw)
raw.filter_map do |file_data|
next if excluded.include?(file_data[:filename])
Artifact.new(
filename: file_data[:filename],
content: file_data[:content],
content_type: file_data[:content_type]
)
end
end
private
# ResponseParser annotates dropped apply_patch parts on the messages
# it processes (since v1.15+ wire shape carries no inline post-write
# content). The notify lives here, not in the parser, so the parser
# stays a pure function. Operators see one event per assistant
# message that contained an apply_patch tool call.
def notify_drops(_)
@messages.each do |message|
next unless message.dig(:info, :role) == "assistant"
parts = message[:parts] || []
parts.each do |part|
next unless part[:type] == "tool" && part[:tool] == "apply_patch"
next unless part.dig(:state, :status) == "completed"
file_entries = part.dig(:state, :metadata, :files) || []
eligible = file_entries.reject { |e| e[:type] == "delete" }
next if eligible.empty?
Opencode::Instrumentation.instrument("opencode.apply_patch.artifacts_dropped",
file_count: eligible.size,
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
message_id: part[:messageID],
session_id: part[:sessionID],
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
"extraction requires sandbox-read which is not yet wired into ResponseParser") { }
end
end
end
end
end