diff --git a/backends/opencode/code-close-diff.sh b/backends/opencode/code-close-diff.sh new file mode 100755 index 0000000..163c847 --- /dev/null +++ b/backends/opencode/code-close-diff.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# code-close-diff.sh — PostToolUse hook entry for OpenCode. +# +# Single RPC into the in-process orchestrator (lua/code-preview/post_tool.lua). +# The orchestrator clears the changes registry, closes any open preview for +# the affected file, and refreshes neo-tree. +# +# When Neovim is unreachable, the shim abstains silently (exit 0). + +# No `set -e`: abstain on jq/nvim_call failure rather than surfacing a +# hook failure to the agent. See the matching note in code-preview-diff.sh. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$SCRIPT_DIR/../../bin" + +INPUT="$(cat)" +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" + +source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +source "$BIN_DIR/nvim-call.sh" + +if [[ -z "${NVIM_SOCKET:-}" ]]; then + exit 0 +fi + +ARGS="$(jq -nc --argjson r "$INPUT" --arg b opencode '[$r, $b]' 2>/dev/null || true)" +[[ -z "$ARGS" ]] && exit 0 +nvim_call code-preview.post_tool handle "$ARGS" >/dev/null diff --git a/backends/opencode/code-preview-diff.sh b/backends/opencode/code-preview-diff.sh new file mode 100755 index 0000000..0c478e9 --- /dev/null +++ b/backends/opencode/code-preview-diff.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# code-preview-diff.sh — PreToolUse hook entry for OpenCode. +# +# After issue #47 phase 3, this shim is a thin wrapper around a single RPC +# into the in-process orchestrator (lua/code-preview/pre_tool/init.lua). The +# TS plugin (backends/opencode/index.ts) collects OpenCode's {tool, args, +# directory} into a JSON payload, pipes it to this shim, and awaits the +# result. Lua-side normalisation maps the camelCase/lowercase shape into the +# canonical form. See docs/adr/0006-opencode-defers-os-independence-to-46.md. +# +# When Neovim is unreachable, the shim abstains silently (exit 0). + +# No `set -e`: the shim is the boundary between the agent and the plugin. +# When jq fails on a malformed payload or nvim_call returns rc=2, we want +# to exit 0 (abstain) so the agent falls back to its native flow rather +# than seeing a hook failure. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$SCRIPT_DIR/../../bin" + +INPUT="$(cat)" +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" + +# Socket discovery — silent failure is fine, we abstain below. +source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +source "$BIN_DIR/nvim-call.sh" + +if [[ -z "${NVIM_SOCKET:-}" ]]; then + exit 0 +fi + +ARGS="$(jq -nc --argjson r "$INPUT" --arg b opencode '[$r, $b]' 2>/dev/null || true)" +# Malformed payload (jq couldn't parse) — abstain silently. +[[ -z "$ARGS" ]] && exit 0 +nvim_call code-preview.pre_tool handle "$ARGS" diff --git a/backends/opencode/index.ts b/backends/opencode/index.ts index 62e68a6..e8bc6d3 100644 --- a/backends/opencode/index.ts +++ b/backends/opencode/index.ts @@ -1,105 +1,98 @@ -// index.ts — OpenCode plugin entry point +// index.ts — OpenCode plugin entry point. // -// Thin adapter that translates OpenCode's hook format to the normalized -// JSON format and delegates to the unified core shell scripts. +// After issue #47 phase 3, this plugin is a thin transport layer. It collects +// OpenCode's {tool, args, directory} from each hook firing, JSON-encodes it, +// and pipes it into the shell shim under backends/opencode/, which performs +// socket discovery and RPCs the in-process orchestrator. Tool-name and +// camelCase→snake_case mapping live Lua-side (pre_tool.normalisers.opencode). +// +// See docs/adr/0006-opencode-defers-os-independence-to-46.md for why this +// keeps the bash shim instead of speaking nvim RPC directly from TS. import type { Plugin } from "@opencode-ai/plugin" import { execSync } from "child_process" -import { readFileSync } from "fs" +import { existsSync, readFileSync } from "fs" import { resolve, dirname } from "path" import { fileURLToPath } from "url" -// ── Resolve path to bin/ directory ─────────────────────────────── - const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -function binDir(): string { - // When installed to .opencode/plugins/, bin-path.txt contains the - // absolute path to the plugin's bin/ directory. +// ── Shim path resolution ───────────────────────────────────────── +// bin-path.txt was historically written by the installer pointing at the +// plugin's bin/ directory. Phase 3 changes its meaning to the plugin root +// (so we can locate backends/opencode/ alongside bin/). For users who +// upgrade without re-running :CodePreviewInstallOpenCodeHooks, fall back to +// the legacy interpretation by stepping up one directory. +// +// Transitional for v2.3; remove the legacy fallback in v3.0. + +function resolveShim(name: string): string | null { + const root = readBinPath() + if (!root) return null + const primary = resolve(root, "backends/opencode", name) + if (existsSync(primary)) return primary + const legacy = resolve(root, "..", "backends/opencode", name) + if (existsSync(legacy)) return legacy + return null +} + +function readBinPath(): string | null { try { return readFileSync(resolve(__dirname, "bin-path.txt"), "utf-8").trim() } catch { - // Fallback for development: resolve relative to plugin source - return resolve(__dirname, "../../bin") + // Development fallback: plugin source lives at /backends/opencode/. + return resolve(__dirname, "../..") } } -// ── Tool name mapping ──────────────────────────────────────────── - -const TOOL_MAP: Record = { - edit: "Edit", - write: "Write", - multiedit: "MultiEdit", - bash: "Bash", - apply_patch: "ApplyPatch", -} - -// ── Format translation ────────────────────────────────────────── - -function toNormalizedJson( - tool: string, - args: Record, - cwd: string, -): string | null { - const toolName = TOOL_MAP[tool] - if (!toolName) return null - - const toolInput: Record = {} - - // Resolve file path (OpenCode may pass relative paths) - if (args.filePath !== undefined) { - const p = args.filePath as string - toolInput.file_path = p && !p.startsWith("/") ? resolve(cwd, p) : p - } - - // Edit fields (camelCase → snake_case) - if (args.oldString !== undefined) toolInput.old_string = args.oldString - if (args.newString !== undefined) toolInput.new_string = args.newString - if (args.replaceAll !== undefined) toolInput.replace_all = args.replaceAll - - // Write fields - if (args.content !== undefined) toolInput.content = args.content - - // MultiEdit fields - if (args.edits !== undefined) { - toolInput.edits = (args.edits as any[]).map((e) => ({ - old_string: e.oldString, - new_string: e.newString, - })) +// ── Tool allowlist ─────────────────────────────────────────────── +// OpenCode tools as of 2026-05-19 the plugin previews: edit, write, +// multiedit, bash, apply_patch. Tools we deliberately ignore (fire-and- +// forget reads): read, glob, grep, list, todoread, todowrite, webfetch, +// websearch, task. Short-circuiting these here saves a bash fork + RPC per +// firing — OpenCode reads/greps prolifically. +// +// Symptom of forgetting to add a new structured-edit tool: no diff appears +// for it. Update this set and pre_tool.normalisers.opencode's tool map +// together. +const PREVIEW_TOOLS = new Set(["edit", "write", "multiedit", "bash", "apply_patch"]) + +// ── Shim invocation ────────────────────────────────────────────── + +function runShim(scriptName: string, payload: object): void { + const shim = resolveShim(scriptName) + if (!shim) { + // Symmetric with the timeout branch below: surface enough breadcrumb + // that a misconfigured bin-path.txt isn't a silently-broken plugin. + // eslint-disable-next-line no-console + console.debug(`[code-preview] could not resolve shim ${scriptName}`) + return } - - // Bash fields - if (args.command !== undefined) toolInput.command = args.command - - // ApplyPatch fields — handle both possible field names from different models - if (args.patchText !== undefined) toolInput.patch_text = args.patchText - if (args.patch !== undefined) toolInput.patch_text = args.patch - - return JSON.stringify({ tool_name: toolName, cwd, tool_input: toolInput }) -} - -// ── Core script execution ─────────────────────────────────────── - -function runCoreScript(script: string, json: string): void { - const bin = binDir() try { - execSync(`"${bin}/${script}"`, { - input: json, + execSync(`"${shim}"`, { + input: JSON.stringify(payload), env: { ...process.env, CODE_PREVIEW_BACKEND: "opencode" }, timeout: 15000, stdio: ["pipe", "pipe", "pipe"], }) - } catch { - // Errors are non-fatal — the diff preview is best-effort + } catch (err: any) { + // Abstain on any failure. Log timeouts at debug-equivalent because + // a silent 15s hang is otherwise hard to diagnose; everything else + // is treated as best-effort and swallowed. + if (err && (err.code === "ETIMEDOUT" || err.signal === "SIGTERM")) { + // eslint-disable-next-line no-console + console.debug(`[code-preview] ${scriptName} timed out after 15s`) + } } } -// ── Hook serialization ───────────────────────────────────────── -// OpenCode may fire tool.execute.after(file1) and tool.execute.before(file2) -// concurrently. Without serialization, the close_diff from file1's after-hook -// races with the show_diff from file2's before-hook, killing file2's preview. -// A simple queue ensures hooks execute one at a time. +// ── Hook serialisation ─────────────────────────────────────────── +// TS→nvim send-order preservation: OpenCode fires before(A) and after(B) +// concurrently; without this, RPCs can reorder during socket discovery and a +// post-tool close can land before its matching pre-tool open. The in-process +// Lua orchestrator serialises *within* nvim's main thread, but cannot fix +// out-of-order arrivals from the TS side. let hookQueue: Promise = Promise.resolve() @@ -110,22 +103,22 @@ function enqueueHook(fn: () => void): Promise { return hookQueue } -// ── Plugin entry point ────────────────────────────────────────── +// ── Plugin entry point ─────────────────────────────────────────── const plugin: Plugin = async ({ directory }) => { return { "tool.execute.before": async (input, output) => { - const args = output.args as Record - const json = toNormalizedJson(input.tool, args, directory) - if (!json) return - await enqueueHook(() => runCoreScript("core-pre-tool.sh", json)) + if (!PREVIEW_TOOLS.has(input.tool)) return + const args = (output.args as Record) ?? {} + const payload = { tool: input.tool, args, cwd: directory } + await enqueueHook(() => runShim("code-preview-diff.sh", payload)) }, "tool.execute.after": async (input, _output) => { - const args = (input as any).args ?? {} - const json = toNormalizedJson(input.tool, args, directory) - if (!json) return - await enqueueHook(() => runCoreScript("core-post-tool.sh", json)) + if (!PREVIEW_TOOLS.has(input.tool)) return + const args = ((input as any).args as Record) ?? {} + const payload = { tool: input.tool, args, cwd: directory } + await enqueueHook(() => runShim("code-close-diff.sh", payload)) }, } } diff --git a/docs/adr/0006-opencode-defers-os-independence-to-46.md b/docs/adr/0006-opencode-defers-os-independence-to-46.md new file mode 100644 index 0000000..946512e --- /dev/null +++ b/docs/adr/0006-opencode-defers-os-independence-to-46.md @@ -0,0 +1,17 @@ +# OpenCode's integration keeps the bash shim, deferring OS-independence to issue #46 + +Issue #47 phase 3 flips OpenCode's [hook entry](../../CONTEXT.md#hook-entry) from `execSync`ing the bash [core handler](../../CONTEXT.md#core-handler) to a single [RPC](../../CONTEXT.md#rpc) into the in-process Lua orchestrator (see [ADR-0005](0005-core-handler-runs-in-process.md)). The natural next question: OpenCode's plugin is TypeScript, which runs natively on Windows. Should the flip also make OpenCode's integration the *first* bash-free [integration](../../CONTEXT.md#integration) — TS calls `nvim --server` directly, or speaks msgpack-rpc, with [socket discovery](../../CONTEXT.md#socket-discovery) reimplemented in TS? + +We decided no. The TS plugin continues to `execSync` a thin `backends/opencode/code-preview-diff.sh` shim that sources `bin/nvim-socket.sh` and `bin/nvim-call.sh`, identical in shape to the claudecode shim. + +## Considered Options + +- **TS-native discovery** — reimplement the pidfile read, the `/var/folders/*/T/nvim.*/0` / `/tmp/${NVIM_APPNAME}.*/0` / `$XDG_RUNTIME_DIR` glob fallbacks, the cwd-matching tiebreak, and the stale-socket probe in TypeScript. Pro: OpenCode becomes the first integration that doesn't need bash. Con: two divergent discovery implementations — every change to `bin/nvim-socket.sh` (e.g. issue #47 phase 1's pidfile work) has to land twice and stay in sync. +- **JS msgpack-rpc client** — add `neovim` or similar to the OpenCode plugin's dependencies and speak the protocol directly. Same divergent-implementation problem, plus a new runtime dependency to manage. +- **Bash shim** *(chosen)* — claudecode's pattern. One discovery implementation; OpenCode pays a bash dependency. + +## Consequences + +- OpenCode's integration still requires bash to be on `PATH`, which is the very thing issue #47 was originally opened to fix for Windows users. The Windows story for OpenCode users does not improve in this phase. +- Issue #46 (centralised RPC/discovery rewrite) inherits exactly one target to port, not two. When #46 lands, OpenCode and claudecode flip together. +- This ADR is **not** a claim that bash is the right end state for OpenCode. It is a deliberate deferral: the TS plugin's natural cross-platform reach is being held back so that #46 has a single discovery implementation to replace. Once #46 ships, OpenCode is expected to be among the first integrations to drop the bash dependency. diff --git a/lua/code-preview/backends/opencode.lua b/lua/code-preview/backends/opencode.lua index 4e27a18..95c2bba 100644 --- a/lua/code-preview/backends/opencode.lua +++ b/lua/code-preview/backends/opencode.lua @@ -10,10 +10,6 @@ local function plugin_root() return vim.fn.fnamemodify(lua_dir, ":h:h:h") end -local function bin_dir() - return plugin_root() .. "/bin" -end - local function plugin_source_dir() return plugin_root() .. "/backends/opencode" end @@ -44,14 +40,30 @@ function M.install() end end - -- Write bin-path.txt so the plugin can find the core scripts + -- Write bin-path.txt pointing at the plugin root. The TS plugin derives + -- both backends/opencode/ (shim location) and bin/ (legacy callers) from + -- this single path. Historically this file pointed at bin/ directly; the + -- TS side keeps a transitional fallback for that legacy value. local bin_path_file = target .. "/bin-path.txt" local bf = io.open(bin_path_file, "w") if bf then - bf:write(bin_dir()) + bf:write(plugin_root()) bf:close() end + -- Ensure shim scripts are executable in-tree. The TS plugin execSyncs them + -- directly from /backends/opencode/. No-op on Windows: the + -- permissions model differs and bash isn't the end-state for opencode-on- + -- Windows anyway (see ADR-0006). #46 will resolve the Windows story. + if vim.fn.has("unix") == 1 then + for _, script in ipairs({ "code-preview-diff.sh", "code-close-diff.sh" }) do + local script_path = source .. "/" .. script + if vim.fn.filereadable(script_path) == 1 then + vim.fn.system({ "chmod", "+x", script_path }) + end + end + end + vim.notify("[code-preview] OpenCode plugin installed → " .. target, vim.log.levels.INFO) end diff --git a/lua/code-preview/post_tool.lua b/lua/code-preview/post_tool.lua index f5e8868..b1265ab 100644 --- a/lua/code-preview/post_tool.lua +++ b/lua/code-preview/post_tool.lua @@ -32,7 +32,9 @@ local function patch_paths(patch_text, cwd) end local custom = line:match("^%*%*%* %a+ File:%s*(.+)$") if custom then - table.insert(paths, custom:gsub("%s+$", "")) + -- gsub returns (string, count); parens discard the count so table.insert + -- doesn't fall into its (t, pos, value) 3-arg form. + table.insert(paths, (custom:gsub("%s+$", ""))) end end -- Resolve relative paths against cwd. diff --git a/lua/code-preview/pre_tool/normalisers.lua b/lua/code-preview/pre_tool/normalisers.lua index 0ea4100..cedaec5 100644 --- a/lua/code-preview/pre_tool/normalisers.lua +++ b/lua/code-preview/pre_tool/normalisers.lua @@ -8,10 +8,10 @@ -- cwd = "/abs/path", -- tool_input = { file_path, ..., (tool-specific fields) } } -- --- Today most backends pre-normalise on their own side (Claude Code's hook --- format is already canonical; OpenCode's TS plugin maps camelCase → --- snake_case before invoking us), so most entries here are identity. New --- backends slot in by adding a function to the table. +-- Claude Code's hook format is already canonical, so its entry is identity. +-- OpenCode fires hooks with lowercase tool names and camelCase argument keys, +-- so the opencode normaliser maps both into the canonical shape. New backends +-- slot in by adding a function to the table. local M = {} @@ -19,9 +19,73 @@ local function identity(raw) return raw end +-- OpenCode tools as of 2026-05-19: edit, write, multiedit, bash, apply_patch +-- (plus read, glob, grep, which the TS-side allowlist filters out before they +-- ever reach this normaliser). Update this map when OpenCode adds a tool the +-- plugin should preview. +local OPENCODE_TOOL_MAP = { + edit = "Edit", + write = "Write", + multiedit = "MultiEdit", + bash = "Bash", + apply_patch = "ApplyPatch", +} + +-- Resolve a possibly-relative filePath against cwd, then collapse ".."/"." +-- segments so internal keys (active_diffs, changes registry) are canonical. +-- Matches Node's path.resolve semantics the old TS plugin used; without it +-- opencode keys could be raw "/proj/../escape.txt" strings that don't +-- compare equal to claudecode-shaped keys for the same logical file. +local function resolve_path(p, cwd) + if not p or p == "" then return p end + local abs = p + if p:sub(1, 1) ~= "/" and cwd and cwd ~= "" then + abs = cwd .. "/" .. p + end + return vim.fs.normalize(abs) +end + +local function opencode(raw) + local tool = raw and raw.tool or "" + local args = (raw and raw.args) or {} + local cwd = (raw and raw.cwd) or "" + + local tool_input = {} + + if args.filePath ~= nil then + tool_input.file_path = resolve_path(args.filePath, cwd) + end + if args.oldString ~= nil then tool_input.old_string = args.oldString end + if args.newString ~= nil then tool_input.new_string = args.newString end + if args.replaceAll ~= nil then tool_input.replace_all = args.replaceAll end + if args.content ~= nil then tool_input.content = args.content end + if args.command ~= nil then tool_input.command = args.command end + + if type(args.edits) == "table" then + local edits = {} + for i, e in ipairs(args.edits) do + edits[i] = { + old_string = e.oldString, + new_string = e.newString, + } + end + tool_input.edits = edits + end + + -- ApplyPatch field name varies across models (`patch` vs `patchText`). + if args.patchText ~= nil then tool_input.patch_text = args.patchText end + if args.patch ~= nil then tool_input.patch_text = args.patch end + + return { + tool_name = OPENCODE_TOOL_MAP[tool], + cwd = cwd, + tool_input = tool_input, + } +end + M.normalisers = { claudecode = identity, - opencode = identity, + opencode = opencode, -- codex / copilot / gemini will land their own normalisers as they flip. } diff --git a/tests/plugin/post_tool_handle_spec.lua b/tests/plugin/post_tool_handle_spec.lua index b2db3af..7ca2fc9 100644 --- a/tests/plugin/post_tool_handle_spec.lua +++ b/tests/plugin/post_tool_handle_spec.lua @@ -55,6 +55,37 @@ describe("post_tool.handle (return value)", function() end) end) +describe("post_tool.handle (ApplyPatch)", function() + it("custom-patch format (*** Update File:) closes one preview per file", function() + -- Regression: gsub returns (string, count); without parens around the + -- gsub call, table.insert falls into its 3-arg (t, pos, value) form and + -- raises "bad argument #2 to 'insert' (number expected, got string)". + -- Beyond not crashing, post_tool must call diff.close_for_file once per + -- patched path with the cwd-resolved absolute path. + local diff = require("code-preview.diff") + local closed = {} + local orig = diff.close_for_file + diff.close_for_file = function(p) table.insert(closed, p) end + + local patch = table.concat({ + "*** Begin Patch", + "*** Update File: a.txt", + "@@", + "-old", + "+new", + "*** Update File: b.txt", + "@@", + "-old", + "+new", + "*** End Patch", + }, "\n") + post_tool.handle(payload("ApplyPatch", { patch_text = patch }), "claudecode") + diff.close_for_file = orig + + assert.are.same({ "/proj/a.txt", "/proj/b.txt" }, closed) + end) +end) + describe("post_tool.handle (robustness)", function() it("missing tool_input does not raise", function() assert.has_no.errors(function() diff --git a/tests/plugin/pre_tool_handle_spec.lua b/tests/plugin/pre_tool_handle_spec.lua index eced6dd..b5326da 100644 --- a/tests/plugin/pre_tool_handle_spec.lua +++ b/tests/plugin/pre_tool_handle_spec.lua @@ -72,7 +72,8 @@ describe("pre_tool.handle (emitter output)", function() end) it("opencode emits empty stdout", function() - local out = pre_tool.handle(payload("Edit", { file_path = "/tmp/x" }), "opencode") + local raw = { tool = "edit", cwd = "/proj", args = { filePath = "/tmp/x" } } + local out = pre_tool.handle(raw, "opencode") assert.equals("", out) end) diff --git a/tests/plugin/pre_tool_normaliser_spec.lua b/tests/plugin/pre_tool_normaliser_spec.lua index 0c785f4..65af96a 100644 --- a/tests/plugin/pre_tool_normaliser_spec.lua +++ b/tests/plugin/pre_tool_normaliser_spec.lua @@ -1,14 +1,12 @@ -- pre_tool_normaliser_spec.lua — Per-backend hook payload normalisation. -- --- Today every supported backend pre-normalises on its own side (Claude Code's --- hook format is already canonical; OpenCode's TS plugin maps camelCase → --- snake_case before invoking us), so the entries here are identity. The --- value of this spec is locking the *contract* — a future backend that --- doesn't pre-normalise will add a non-identity entry and a row here. +-- Claude Code's hook format is already canonical, so its normaliser is +-- identity. OpenCode fires hooks with lowercase tool names and camelCase +-- argument keys, so its normaliser maps both into the canonical shape. local normalisers = require("code-preview.pre_tool.normalisers") -describe("normalisers.normalise", function() +describe("normalisers.normalise (claudecode)", function() local canonical = { tool_name = "Edit", cwd = "/proj", @@ -19,11 +17,86 @@ describe("normalisers.normalise", function() assert.are.same(canonical, normalisers.normalise(canonical, "claudecode")) end) - it("opencode is identity (pre-normalised by TS plugin)", function() - assert.are.same(canonical, normalisers.normalise(canonical, "opencode")) - end) - it("unknown backend falls back to identity", function() assert.are.same(canonical, normalisers.normalise(canonical, "future-agent")) end) end) + +describe("normalisers.normalise (opencode)", function() + it("maps tool name and Edit fields, resolves relative path", function() + local raw = { + tool = "edit", + cwd = "/proj", + args = { filePath = "foo.lua", oldString = "a", newString = "b", replaceAll = true }, + } + assert.are.same({ + tool_name = "Edit", + cwd = "/proj", + tool_input = { + file_path = "/proj/foo.lua", + old_string = "a", + new_string = "b", + replace_all = true, + }, + }, normalisers.normalise(raw, "opencode")) + end) + + it("preserves absolute filePath", function() + local raw = { tool = "write", cwd = "/proj", args = { filePath = "/abs/x", content = "x" } } + local out = normalisers.normalise(raw, "opencode") + assert.equals("/abs/x", out.tool_input.file_path) + assert.equals("Write", out.tool_name) + assert.equals("x", out.tool_input.content) + end) + + it("collapses .. segments to canonical path", function() + -- Matches the old TS plugin's path.resolve semantics so internal keys + -- compare equal across backends. + local raw = { tool = "edit", cwd = "/proj/sub", args = { filePath = "../foo.lua" } } + local out = normalisers.normalise(raw, "opencode") + assert.equals("/proj/foo.lua", out.tool_input.file_path) + end) + + it("maps MultiEdit edits array", function() + local raw = { + tool = "multiedit", + cwd = "/proj", + args = { + filePath = "/proj/f", + edits = { + { oldString = "a", newString = "A" }, + { oldString = "b", newString = "B" }, + }, + }, + } + local out = normalisers.normalise(raw, "opencode") + assert.equals("MultiEdit", out.tool_name) + assert.are.same({ + { old_string = "a", new_string = "A" }, + { old_string = "b", new_string = "B" }, + }, out.tool_input.edits) + end) + + it("maps Bash command", function() + local raw = { tool = "bash", cwd = "/proj", args = { command = "ls" } } + local out = normalisers.normalise(raw, "opencode") + assert.equals("Bash", out.tool_name) + assert.equals("ls", out.tool_input.command) + end) + + it("accepts both patch and patchText for ApplyPatch", function() + local a = normalisers.normalise( + { tool = "apply_patch", cwd = "/proj", args = { patch = "PATCH_A" } }, "opencode") + local b = normalisers.normalise( + { tool = "apply_patch", cwd = "/proj", args = { patchText = "PATCH_B" } }, "opencode") + assert.equals("ApplyPatch", a.tool_name) + assert.equals("PATCH_A", a.tool_input.patch_text) + assert.equals("PATCH_B", b.tool_input.patch_text) + end) + + it("unknown tool yields nil tool_name (dispatched as no-op upstream)", function() + local out = normalisers.normalise( + { tool = "read", cwd = "/proj", args = { filePath = "/proj/x" } }, "opencode") + assert.is_nil(out.tool_name) + end) +end)