opencode-rails — production-grade Rails integration for OpenCode. Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle (idempotent ensure!/recreate!/abort! with row-level locks), a Turn orchestrator driving the Reply state machine and recovering from session-not-found, an artifact pipeline backed by ActiveStorage, sandbox seeding, and tool-display value objects for Turbo Stream broadcasts. Drop into any Rails 7.1+ app that wants production-grade OpenCode streaming without rolling boilerplate. What this version ships: - Opencode::Session (AR-coupled lifecycle, row-level locks) - Opencode::Turn (Reply state machine, session-not-found recovery) - Opencode::Exchange (one turn = one request/response unit) - Opencode::Impostor (deterministic mock for tests) - Opencode::Sandbox / SandboxFile (per-session FS scratch space) - Opencode::Transform (host-rendered artifact pipeline) - Opencode::Artifact / MessageArtifacts (ActiveStorage-backed) - Opencode::UploadedFilesPrompt (system-prompt builder) - Opencode::ToolDisplay (Turbo Stream value objects) - Opencode::ErrorReporter (pluggable adapter — Honeybadger/Sentry/etc.) - examples/rails_integration.rb — canonical wiring blueprint 53 smoke tests. CI on Ruby 3.2/3.3/3.4. Ruby >= 3.2. Runtime deps: opencode-ruby = 0.0.1.alpha2, activerecord/activestorage/activesupport >= 7.1, < 9.0. See CHANGELOG.md for the alpha1 -> alpha2 delta.
424 lines
18 KiB
Ruby
424 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Opencode
|
|
# A value object that wraps an OpenCode tool part (the shape produced by
|
|
# Opencode::Reply from `message.part.updated` events) and exposes the
|
|
# information a renderer needs — canonical tool name, human labels,
|
|
# target (filepath / pattern / url / command), semantic accessors for
|
|
# rich content (unified diffs, todo lists, bash output, etc.), and an
|
|
# icon identifier.
|
|
#
|
|
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
|
|
# so any view that renders OpenCode tool calls — across whatever
|
|
# products the host runs — can do so consistently.
|
|
#
|
|
# ## Data shape (Opencode::Reply writes this into `parts_json`)
|
|
#
|
|
# {
|
|
# "type" => "tool",
|
|
# "tool" => "read" | "edit" | "exa_web_search_exa" | ...,
|
|
# "status" => "pending" | "running" | "completed" | "error",
|
|
# "input" => { "filePath" => ..., "content" => ..., ... },
|
|
# "title" => "..." (optional, from tool-result)
|
|
# "error" => "..." (only when status == "error")
|
|
# "metadata" => { (optional, from tool-result)
|
|
# "diff" => "...unified diff text...",
|
|
# "diagnostics" => { filePath => [LSP diagnostics] },
|
|
# "preview" => "...file preview...",
|
|
# "matches" => Integer,
|
|
# "count" => Integer,
|
|
# "output" => "...bash stdout...",
|
|
# "stdout" => "...bash stdout (legacy key)...",
|
|
# "description" => "...bash description...",
|
|
# "error" => truthy when the tool ran but returned an error,
|
|
# },
|
|
# "output" => "...raw tool output string..."
|
|
# }
|
|
#
|
|
# ## MCP prefix handling
|
|
#
|
|
# OpenCode's MCP adapter prefixes tools with the server name: Exa's
|
|
# `web_search_exa` becomes `exa_web_search_exa` on the wire (double
|
|
# suffix because Exa also names the tool with an `_exa` suffix).
|
|
# `#canonical_tool` strips known MCP prefixes so switching logic can
|
|
# treat `exa_web_search_exa` and `web_search_exa` as the same tool.
|
|
#
|
|
# ## Adding a new tool
|
|
#
|
|
# Add one row to the `TOOLS` table below — every derived concern (kind,
|
|
# gerund, icon, past-tense verb, KNOWN membership) is computed from it.
|
|
#
|
|
# ## Example
|
|
#
|
|
# display = Opencode::ToolDisplay.new(part)
|
|
# display.canonical_tool # => "edit"
|
|
# display.kind # => "Edit"
|
|
# display.gerund # => "Editing"
|
|
# display.target # => "app/models/user.rb"
|
|
# display.diff # => "@@ -1,3 +1,4 @@\n..."
|
|
# display.icon # => :pencil_square
|
|
#
|
|
class ToolDisplay
|
|
# The single source of truth for every tool we render with dedicated
|
|
# affordances. One row per tool, five columns:
|
|
#
|
|
# :kind — noun label ("Read", "Web search")
|
|
# :gerund — present-progressive phrase ("Reading", "Searching the web")
|
|
# :icon — abstract icon name the view layer maps to an SVG
|
|
# :past — past-tense verb ("Read", "Searched", "Wrote")
|
|
#
|
|
# Anything not in this table falls back to generic rendering (humanize
|
|
# the canonical name + OpenCode's title).
|
|
TOOLS = {
|
|
"read" => { kind: "Read", gerund: "Reading", icon: :document, past: "Read" },
|
|
"write" => { kind: "Write", gerund: "Writing", icon: :document_plus, past: "Wrote" },
|
|
"edit" => { kind: "Edit", gerund: "Editing", icon: :pencil_square, past: "Edited" },
|
|
"multiedit" => { kind: "Edit", gerund: "Editing", icon: :pencil_square, past: "Edited" },
|
|
"apply_patch" => { kind: "Patch", gerund: "Applying changes", icon: :pencil_square, past: "Applied changes to" },
|
|
"bash" => { kind: "Bash", gerund: "Running command", icon: :command_line, past: "Ran" },
|
|
"grep" => { kind: "Grep", gerund: "Searching files", icon: :document_magnifying_glass, past: "Searched for" },
|
|
"glob" => { kind: "Glob", gerund: "Searching files", icon: :magnifying_glass, past: "Searched for" },
|
|
"list" => { kind: "LS", gerund: "Listing files", icon: :rectangle_stack, past: "Listed" },
|
|
"ls" => { kind: "LS", gerund: "Listing files", icon: :rectangle_stack, past: "Listed" },
|
|
"webfetch" => { kind: "Fetch", gerund: "Reading web page", icon: :globe, past: "Fetched" },
|
|
"websearch" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
|
|
"codesearch" => { kind: "Code search", gerund: "Searching code", icon: :magnifying_glass, past: "Searched code for" },
|
|
"web_search_exa" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
|
|
# @zhafron/mcp-web-search exposes two tools: search_web (SearXNG meta-
|
|
# search) and fetch_url (Mozilla Readability page fetch). OpenCode
|
|
# prefixes them with the MCP server name from config.json
|
|
# (`local-web-search_`), which `canonical_tool` strips before lookup.
|
|
"search_web" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
|
|
"fetch_url" => { kind: "Fetch", gerund: "Reading web page", icon: :globe, past: "Fetched" },
|
|
"get_code_context_exa" => { kind: "Code lookup", gerund: "Looking up code", icon: :document_magnifying_glass, past: "Looked up" },
|
|
"company_research_exa" => { kind: "Company research", gerund: "Researching company", icon: :globe, past: "Researched" },
|
|
"todowrite" => { kind: "Plan", gerund: "Planning", icon: :queue_list, past: "Updated plan" },
|
|
"todoread" => { kind: "Plan", gerund: "Reading plan", icon: :queue_list, past: "Read plan" },
|
|
"task" => { kind: "Task", gerund: "Researching", icon: :robot, past: "Ran subtask" },
|
|
"skill" => { kind: "Skill", gerund: "Loading skill", icon: :sparkles, past: "Loaded skill" }
|
|
}.freeze
|
|
|
|
KNOWN = TOOLS.keys.freeze
|
|
DEFAULT_ICON = :sparkles
|
|
|
|
# MCP server prefixes to strip, paired with the tools they canonicalize
|
|
# to (for `#provider` classification). Prefixes sorted by length
|
|
# descending in case future additions overlap.
|
|
PROVIDERS = {
|
|
"exa" => { prefix: "exa_", canonical: %w[web_search_exa get_code_context_exa company_research_exa].freeze },
|
|
"brave" => { prefix: "brave_", canonical: [].freeze },
|
|
"serper" => { prefix: "serper_", canonical: [].freeze },
|
|
"tavily" => { prefix: "tavily_", canonical: [].freeze },
|
|
# The local-web-search MCP server registered in
|
|
# config/opencode/<product>/config.json. Hyphenated server name plus
|
|
# underscore separator; canonical tools are search_web and fetch_url.
|
|
"local-web-search" => { prefix: "local-web-search_", canonical: %w[search_web fetch_url].freeze }
|
|
}.freeze
|
|
|
|
MCP_PREFIXES = PROVIDERS.values.map { |p| p[:prefix] }.freeze
|
|
|
|
attr_reader :part
|
|
|
|
def initialize(part)
|
|
@part = part || {}
|
|
end
|
|
|
|
# Convenience constructor for raw OpenCode API parts (symbol keys,
|
|
# nested under `state`). Flattens into the canonical `parts_json`
|
|
# shape Reply persists.
|
|
#
|
|
# raw = { type: "tool", tool: "bash", callID: ...,
|
|
# state: { status: "running", input: {...}, title: "..." } }
|
|
# Opencode::ToolDisplay.from_raw(raw)
|
|
def self.from_raw(raw)
|
|
raw = (raw || {}).deep_stringify_keys
|
|
state = raw["state"] || {}
|
|
new(
|
|
"type" => "tool",
|
|
"tool" => raw["tool"],
|
|
"status" => state["status"],
|
|
"input" => state["input"] || {},
|
|
"output" => state["output"],
|
|
"title" => state["title"],
|
|
"error" => state["error"],
|
|
"metadata" => state["metadata"] || {}
|
|
)
|
|
end
|
|
|
|
# ----- Identity -----------------------------------------------------
|
|
|
|
def tool_name
|
|
@part["tool"].to_s
|
|
end
|
|
|
|
# Strips MCP prefixes — and, when present, the matching MCP suffix —
|
|
# so tools render cleanly regardless of how the server namespaces them.
|
|
#
|
|
# Examples:
|
|
# exa_web_search_exa → web_search_exa (KNOWN tool, preserved)
|
|
# exa_web_fetch_exa → web_fetch (not KNOWN; cleaned for display)
|
|
# exa_nonexistent → nonexistent (prefix-stripped fallback)
|
|
# brave_read → read (KNOWN after prefix strip)
|
|
#
|
|
# The double-strip handles Exa's naming convention: the MCP server
|
|
# exports tools like `web_fetch_exa`, and OpenCode prepends `exa_`,
|
|
# producing `exa_web_fetch_exa`. Stripping both yields a readable
|
|
# `web_fetch`.
|
|
def canonical_tool
|
|
name = tool_name
|
|
return name if name.empty? || KNOWN.include?(name)
|
|
|
|
MCP_PREFIXES.each do |prefix|
|
|
stripped = strip_mcp_decoration(name, prefix)
|
|
return stripped if stripped
|
|
end
|
|
name
|
|
end
|
|
|
|
def known?
|
|
KNOWN.include?(canonical_tool)
|
|
end
|
|
|
|
def icon
|
|
TOOLS.dig(canonical_tool, :icon) || DEFAULT_ICON
|
|
end
|
|
|
|
# "Read", "Edit", "Bash", or a humanized *canonical* name for unknowns.
|
|
# Humanizes `canonical_tool`, not `tool_name` — otherwise an MCP tool
|
|
# like `exa_web_fetch_exa` whose canonical form (`web_fetch`) isn't
|
|
# in TOOLS would fall back to the raw, MCP-prefixed name and display
|
|
# as "Exa web fetch exa". Using the canonical form gives the clean
|
|
# "Web fetch" label.
|
|
def kind
|
|
TOOLS.dig(canonical_tool, :kind) || canonical_tool.humanize
|
|
end
|
|
|
|
# "Reading", "Editing", "Running command", falls back to "<kind>...".
|
|
def gerund
|
|
TOOLS.dig(canonical_tool, :gerund) || "#{kind}..."
|
|
end
|
|
|
|
# ----- Status -------------------------------------------------------
|
|
|
|
def status
|
|
@part["status"].to_s
|
|
end
|
|
|
|
def pending? = status == "pending"
|
|
def running? = status == "running"
|
|
def completed? = status == "completed"
|
|
def errored? = status == "error"
|
|
def terminal? = completed? || errored?
|
|
def in_flight? = pending? || running?
|
|
|
|
# ----- Raw payloads -------------------------------------------------
|
|
|
|
def input
|
|
@part["input"].is_a?(Hash) ? @part["input"] : {}
|
|
end
|
|
|
|
def metadata
|
|
@part["metadata"].is_a?(Hash) ? @part["metadata"] : {}
|
|
end
|
|
|
|
def output
|
|
@part["output"].to_s
|
|
end
|
|
|
|
def error_text
|
|
@part["error"].to_s
|
|
end
|
|
|
|
# OpenCode-supplied title (from tool-result). Used as a fallback for
|
|
# unknown MCP tools that don't match KNOWN.
|
|
def opencode_title
|
|
@part["title"].to_s
|
|
end
|
|
|
|
# ----- Target (what the tool is operating on) ----------------------
|
|
|
|
# Returns a single-string representation of the tool's primary target,
|
|
# or nil when the tool has no meaningful single target. Callers can
|
|
# substitute display-friendly names (e.g., sandbox filenames).
|
|
def target
|
|
raw = case canonical_tool
|
|
when "read", "write", "edit", "multiedit", "apply_patch"
|
|
input["filePath"] || input["path"]
|
|
when "bash"
|
|
input["command"]
|
|
when "grep", "glob"
|
|
input["pattern"]
|
|
when "list", "ls"
|
|
input["path"]
|
|
when "webfetch", "fetch_url"
|
|
input["url"]
|
|
when "websearch", "codesearch",
|
|
"web_search_exa", "get_code_context_exa", "company_research_exa"
|
|
input["query"]
|
|
when "search_web"
|
|
# @zhafron/mcp-web-search names the argument `q`, not `query`.
|
|
input["q"]
|
|
when "task"
|
|
input["description"]
|
|
when "skill"
|
|
input["skill_name"] || input["name"]
|
|
end
|
|
raw.to_s.presence
|
|
end
|
|
|
|
# A short label combining kind + target, suitable for a one-line
|
|
# summary. Falls back to the OpenCode-supplied title for unknown MCP
|
|
# tools, then to just kind.
|
|
def title
|
|
if known?
|
|
target.present? ? "#{kind}: #{target}" : kind
|
|
else
|
|
opencode_title.presence || kind
|
|
end
|
|
end
|
|
|
|
# Past-tense "done" variant: "Read foo.rb", "Wrote contract.pdf",
|
|
# "Edited user.rb", "Ran `ls -la`". Used after completion.
|
|
def past_tense_title
|
|
if known?
|
|
verb = TOOLS.dig(canonical_tool, :past)
|
|
return kind unless verb
|
|
target.present? ? "#{verb} #{target}" : verb
|
|
else
|
|
opencode_title.presence || kind
|
|
end
|
|
end
|
|
|
|
# ----- Semantic accessors (rich content) ---------------------------
|
|
|
|
# Unified-diff text produced by the edit tool (OpenCode attaches this
|
|
# under metadata.diff). Present only after completion.
|
|
def diff
|
|
metadata["diff"].presence
|
|
end
|
|
|
|
# Sorted list of todo hashes { "content", "status", "priority", "id" }.
|
|
# Available during running (input is populated) and completed states.
|
|
# Order: in_progress first, pending next, completed last.
|
|
# Canonicalization (string keys + status hyphen→underscore) is
|
|
# delegated to Opencode::Todo so Reply and ToolDisplay can't drift.
|
|
def todos
|
|
return [] unless %w[todowrite todoread].include?(canonical_tool)
|
|
items = input["todos"]
|
|
return [] unless items.is_a?(Array)
|
|
order = { "in_progress" => 0, "pending" => 1, "completed" => 2 }
|
|
items
|
|
.select { |t| t.is_a?(Hash) }
|
|
.map { |todo| Opencode::Todo.canonicalize(todo) }
|
|
.sort_by { |todo| order[todo["status"]] || 99 }
|
|
end
|
|
|
|
# File content that the write tool is creating (lives in input.content).
|
|
def file_content
|
|
return nil unless canonical_tool == "write"
|
|
input["content"].presence
|
|
end
|
|
|
|
# Syntax-highlighting language hint based on the target filename.
|
|
# Falls back to the raw extension so unknown file types still get a
|
|
# hint their syntax-highlighter may recognize heuristically.
|
|
def file_lang
|
|
name = File.basename(target.to_s)
|
|
return nil if name.empty?
|
|
ext = File.extname(name).delete_prefix(".").downcase
|
|
LANG_BY_EXT[ext] || ext.presence
|
|
end
|
|
|
|
LANG_BY_EXT = {
|
|
"md" => "markdown", "markdown" => "markdown",
|
|
"rb" => "ruby", "rake" => "ruby",
|
|
"py" => "python",
|
|
"js" => "javascript", "mjs" => "javascript",
|
|
"ts" => "typescript", "tsx" => "typescript",
|
|
"jsx" => "jsx",
|
|
"json" => "json", "yml" => "yaml", "yaml" => "yaml",
|
|
"html" => "html", "erb" => "erb",
|
|
"css" => "css", "scss" => "scss",
|
|
"sh" => "shell", "bash" => "shell", "zsh" => "shell",
|
|
"sql" => "sql",
|
|
"go" => "go", "rs" => "rust",
|
|
"c" => "c", "h" => "c",
|
|
"cpp" => "cpp", "hpp" => "cpp",
|
|
"java" => "java", "kt" => "kotlin",
|
|
"swift" => "swift", "php" => "php",
|
|
"lua" => "lua", "toml" => "toml",
|
|
"xml" => "xml", "conf" => "shell"
|
|
}.freeze
|
|
|
|
# Bash-specific accessors.
|
|
def bash_command
|
|
return nil unless canonical_tool == "bash"
|
|
input["command"].presence
|
|
end
|
|
|
|
def bash_output
|
|
return nil unless canonical_tool == "bash"
|
|
(metadata["output"] || metadata["stdout"] || output).to_s.presence
|
|
end
|
|
|
|
def bash_description
|
|
return nil unless canonical_tool == "bash"
|
|
metadata["description"].presence
|
|
end
|
|
|
|
# Read preview (OpenCode populates metadata.preview after the read).
|
|
def read_preview
|
|
return nil unless canonical_tool == "read"
|
|
metadata["preview"].presence
|
|
end
|
|
|
|
# Grep/Glob match counts.
|
|
def match_count
|
|
case canonical_tool
|
|
when "grep" then metadata["matches"].to_i
|
|
when "glob" then metadata["count"].to_i
|
|
end
|
|
end
|
|
|
|
# ----- Provider identification for log tagging ---------------------
|
|
|
|
# Groups tools by which MCP server / built-in provides them, for
|
|
# operational logs and metrics. Adding a new provider = one row in
|
|
# PROVIDERS.
|
|
def provider
|
|
name = tool_name
|
|
canonical = canonical_tool
|
|
PROVIDERS.each do |provider_name, config|
|
|
return provider_name if name.start_with?(config[:prefix])
|
|
return provider_name if config[:canonical].include?(canonical)
|
|
end
|
|
KNOWN.include?(canonical) ? "opencode-builtin" : "unknown"
|
|
end
|
|
|
|
private
|
|
|
|
# Returns `name` with `prefix` (and the matching MCP suffix, where
|
|
# Exa double-encodes) removed, or nil when `name` doesn't carry that
|
|
# prefix. Precedence:
|
|
#
|
|
# 1. Prefer the single-stripped form when it's KNOWN
|
|
# (exa_web_search_exa → web_search_exa, which IS a TOOLS key).
|
|
# 2. Otherwise prefer the clean double-stripped form when both
|
|
# prefix and suffix are present
|
|
# (exa_web_fetch_exa → web_fetch, for humanization).
|
|
# 3. Fall back to single-stripped when double-stripped is empty.
|
|
def strip_mcp_decoration(name, prefix)
|
|
return nil unless name.start_with?(prefix)
|
|
|
|
stripped = name.delete_prefix(prefix)
|
|
return stripped if KNOWN.include?(stripped)
|
|
|
|
suffix = "_#{prefix.chomp('_')}"
|
|
return stripped unless stripped.end_with?(suffix)
|
|
|
|
double = stripped.delete_suffix(suffix)
|
|
double.empty? ? stripped : double
|
|
end
|
|
end
|
|
end
|