From 1380a000430c4fd98e0f08c76a28af1b8b82cf59 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 22 May 2026 01:44:06 +0530 Subject: [PATCH 1/2] feat: in-process Lua core handler for copilot (#47, phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips Copilot to the in-process orchestrator that claudecode (#63) and opencode (#65) already use. The shims become thin RPC wrappers into code-preview.pre_tool / code-preview.post_tool; the Copilot-specific {toolName, cwd, toolArgs} translation now lives in the new `copilot` entry of pre_tool.normalisers. Copilot has no permission-decision analogue, so its emitter is `none`. When Neovim is unreachable the shims abstain silently (exit 0). Preserved the bash-side noise-tool short-circuit (view/glob/grep/ls/ report_intent) as a perf filter — Copilot has no per-tool hook matcher and no TS-side allowlist, so without this every chatty tool firing would pay for socket discovery plus an RPC round-trip. The Lua map remains the source of truth; the bash case is purely a fast-path. Defensive empty-path/command branch in the normaliser drops tool_name to nil for Edit/Write/Bash/ApplyPatch with blank critical fields, matching the old shim's explicit skip-on-empty guard. Co-Authored-By: Claude Opus 4.7 --- backends/copilot/code-close-diff.sh | 87 +++---------- backends/copilot/code-preview-diff.sh | 146 +++++----------------- lua/code-preview/pre_tool/emitters.lua | 3 +- lua/code-preview/pre_tool/normalisers.lua | 87 ++++++++++++- tests/plugin/pre_tool_normaliser_spec.lua | 146 ++++++++++++++++++++++ 5 files changed, 287 insertions(+), 182 deletions(-) diff --git a/backends/copilot/code-close-diff.sh b/backends/copilot/code-close-diff.sh index 987fc6b..6cce078 100755 --- a/backends/copilot/code-close-diff.sh +++ b/backends/copilot/code-close-diff.sh @@ -1,85 +1,36 @@ #!/usr/bin/env bash -# code-close-diff.sh — PostToolUse hook adapter for GitHub Copilot CLI. +# code-close-diff.sh — PostToolUse hook entry for GitHub Copilot CLI. # -# Mirrors the translation in code-preview-diff.sh and delegates to -# bin/core-post-tool.sh. Only the fields core-post-tool.sh reads are -# populated (tool_name, cwd, file_path or patch_text). +# 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" -export CODE_PREVIEW_BACKEND="copilot" INPUT="$(cat)" -TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // ""')" -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')" - +# Fast-path filter — see the matching note in code-preview-diff.sh. +TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // empty' 2>/dev/null || true)" case "$TOOL" in ""|view|glob|grep|ls|report_intent) exit 0 ;; esac -# Logging — gated on `debug = true` in setup(). -log() { :; } -# shellcheck source=/dev/null -source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -# shellcheck source=/dev/null -source "$BIN_DIR/nvim-call.sh" 2>/dev/null || true -if [[ -n "${NVIM_SOCKET:-}" ]]; then - _CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" - _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) - _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) - if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then - log() { printf '[%s] [INFO] copilot/post: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; } - fi -fi - -log "tool=$TOOL" - -RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')" - -# Bind the key as data via --arg, not interpolated into the jq program. -# Supports single-key lookup only (no dotted paths) — all current callers -# pass a single field like `.path`, `.command`, etc. -arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; } - -resolve_path() { - local p="$1" - if [[ -z "$p" ]]; then printf ''; return; fi - if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi -} +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" -case "$TOOL" in - apply_patch) - NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \ - '{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')" - ;; - - edit|str_replace) - FP="$(resolve_path "$(arg .path)")" - NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \ - '{tool_name:"Edit", cwd:$cwd, tool_input:{file_path:$fp}}')" - ;; - - create|write) - FP="$(resolve_path "$(arg .path)")" - NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \ - '{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp}}')" - ;; - - bash) - CMD="$(arg .command)" - NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \ - '{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')" - ;; - - *) - log "unhandled tool=$TOOL — exiting" - exit 0 - ;; -esac +source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +source "$BIN_DIR/nvim-call.sh" -log "translated tool=$TOOL → closing" +if [[ -z "${NVIM_SOCKET:-}" ]]; then + exit 0 +fi -printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh" +ARGS="$(jq -nc --argjson r "$INPUT" --arg b copilot '[$r, $b]' 2>/dev/null || true)" +[[ -z "$ARGS" ]] && exit 0 +nvim_call code-preview.post_tool handle "$ARGS" >/dev/null diff --git a/backends/copilot/code-preview-diff.sh b/backends/copilot/code-preview-diff.sh index 091caa8..55fb371 100755 --- a/backends/copilot/code-preview-diff.sh +++ b/backends/copilot/code-preview-diff.sh @@ -1,129 +1,51 @@ #!/usr/bin/env bash -# code-preview-diff.sh — PreToolUse hook adapter for GitHub Copilot CLI. +# code-preview-diff.sh — PreToolUse hook entry for GitHub Copilot CLI. # -# Translates Copilot's hook payload (stdin JSON with toolName/toolArgs) into -# the normalized {tool_name, cwd, tool_input} format consumed by -# bin/core-pre-tool.sh, then delegates to it. +# After issue #47 phase 3, this shim does almost nothing: it discovers the +# running Neovim's socket and makes a single RPC call into the in-process +# orchestrator (lua/code-preview/pre_tool/init.lua), then prints whatever the +# orchestrator returns. The bash that used to translate Copilot's +# {toolName, cwd, toolArgs} payload into the canonical hook shape now lives +# in lua/code-preview/pre_tool/normalisers.lua (copilot entry). # -# Field mapping: -# apply_patch → ApplyPatch (toolArgs is raw patch text) -# edit/str_replace → Edit ({path, old_str, new_str}) -# create/write → Write ({path, file_text | content}) -# bash → Bash ({command, description}) -# view/glob/... → ignored -# -# Note: toolArgs is a JSON-encoded string in preToolUse and an object in -# postToolUse; we normalize both to a string so downstream parsing is uniform. - +# When Neovim is unreachable, the shim abstains: exit 0 with no stdout. +# Copilot then falls back to its native flow as if the plugin weren't +# installed. See docs/adr/0005-core-handler-runs-in-process.md. + +# 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" -export CODE_PREVIEW_BACKEND="copilot" INPUT="$(cat)" -TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // ""')" -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')" - -# Noise tools never produce a preview — bail out before the expensive -# socket/log-setup RPC so the log stays clean. +# Fast-path filter for tools that never produce a preview. Copilot has no +# per-tool hook matcher and no TS-side allowlist (unlike Claude Code's +# settings.json and opencode's TS plugin), so every tool firing — including +# the very chatty view/glob/grep/ls/report_intent — would otherwise pay for +# socket discovery + an RPC round-trip just for the Lua normaliser to return +# tool_name=nil. The Lua map in pre_tool.normalisers remains the source of +# truth; this case is purely a perf filter. +TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // empty' 2>/dev/null || true)" case "$TOOL" in ""|view|glob|grep|ls|report_intent) exit 0 ;; esac -# Logging — mirrors core-pre-tool.sh. Gated on `debug = true` in setup(). -log() { :; } -# shellcheck source=/dev/null -source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -# shellcheck source=/dev/null -source "$BIN_DIR/nvim-call.sh" 2>/dev/null || true -_NVIM_SERVERNAME="" -_NVIM_CWD="" -if [[ -n "${NVIM_SOCKET:-}" ]]; then - _CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" - _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) - _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) - _NVIM_SERVERNAME=$(echo "$_CTX" | jq -r '.servername // ""' 2>/dev/null) - _NVIM_CWD=$(echo "$_CTX" | jq -r '.cwd // ""' 2>/dev/null) - if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then - log() { printf '[%s] [INFO] copilot/pre: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; } - fi -fi - -log "tool=$TOOL servername=${_NVIM_SERVERNAME:-} nvim_cwd=${_NVIM_CWD:-} hook_cwd=$CWD" - -# Normalize toolArgs to a raw string. For JSON-object tools this becomes the -# stringified JSON; for apply_patch it's the raw patch text. -RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')" - -# Bind the key as data via --arg, not interpolated into the jq program. -# Supports single-key lookup only (no dotted paths) — all current callers -# pass a single field like `.path`, `.command`, etc. -arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; } - -resolve_path() { - local p="$1" - if [[ -z "$p" ]]; then printf ''; return; fi - if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi -} - -case "$TOOL" in - apply_patch) - NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \ - '{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')" - ;; - - edit|str_replace) - FP="$(resolve_path "$(arg .path)")" - NORMALIZED="$(jq -n \ - --arg cwd "$CWD" \ - --arg fp "$FP" \ - --arg os "$(arg .old_str)" \ - --arg ns "$(arg .new_str)" \ - '{tool_name:"Edit", cwd:$cwd, - tool_input:{file_path:$fp, old_string:$os, new_string:$ns, replace_all:false}}')" - ;; - - create|write) - FP="$(resolve_path "$(arg .path)")" - # Copilot's create uses file_text; fall back to content for other models. - CONTENT="$(printf '%s' "$RAW_ARGS" | jq -r '.file_text // .content // ""')" - NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" --arg c "$CONTENT" \ - '{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp, content:$c}}')" - ;; - - bash) - CMD="$(arg .command)" - NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \ - '{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')" - ;; - - *) - log "unhandled tool=$TOOL — exiting" - exit 0 - ;; -esac +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" -# Guard against malformed payloads (missing toolArgs fields). Sending an -# empty path or command downstream produces a broken/empty diff; a clean -# skip is preferable. apply_patch is already resilient — apply-patch.lua -# parses zero files from an empty patch and exits cleanly. -case "$TOOL" in - edit|str_replace|create|write) - if [[ -z "$FP" ]]; then - log "empty file path for tool=$TOOL — skipping" - exit 0 - fi - ;; - bash) - if [[ -z "$CMD" ]]; then - log "empty command for bash — skipping" - exit 0 - fi - ;; -esac +# 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" -log "translated tool=$TOOL → $(printf '%s' "$NORMALIZED" | jq -c '{tool_name, file: .tool_input.file_path // "", has_patch: (.tool_input.patch_text != null)}' 2>/dev/null || echo 'parse-error')" +if [[ -z "${NVIM_SOCKET:-}" ]]; then + exit 0 +fi -printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh" +ARGS="$(jq -nc --argjson r "$INPUT" --arg b copilot '[$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/lua/code-preview/pre_tool/emitters.lua b/lua/code-preview/pre_tool/emitters.lua index 6c076a7..5cd8e08 100644 --- a/lua/code-preview/pre_tool/emitters.lua +++ b/lua/code-preview/pre_tool/emitters.lua @@ -32,7 +32,8 @@ end M.emitters = { claudecode = claudecode, opencode = none, - -- codex / copilot / gemini default to `none` via the fallback below. + copilot = none, + -- codex / gemini default to `none` via the fallback below. } --- @param backend string diff --git a/lua/code-preview/pre_tool/normalisers.lua b/lua/code-preview/pre_tool/normalisers.lua index cedaec5..13e20e6 100644 --- a/lua/code-preview/pre_tool/normalisers.lua +++ b/lua/code-preview/pre_tool/normalisers.lua @@ -83,10 +83,95 @@ local function opencode(raw) } end +-- Copilot tools as of 2026-05-21: apply_patch, edit, str_replace, create, +-- write, bash (plus view/glob/grep/ls/report_intent which the shim drops +-- before invoking us). `str_replace` and `edit` carry the same {path, +-- old_str, new_str} shape; both alias to Edit. `create` and `write` both +-- alias to Write (file_text vs content). +local COPILOT_TOOL_MAP = { + apply_patch = "ApplyPatch", + edit = "Edit", + str_replace = "Edit", + create = "Write", + write = "Write", + bash = "Bash", +} + +-- Copilot delivers `toolArgs` as a JSON-encoded string in preToolUse and as +-- an object in postToolUse. For apply_patch the string IS the raw patch text +-- (not JSON). For every other tool the string contains a JSON object with +-- snake_case keys (path, old_str, new_str, file_text, command, ...). +local function copilot(raw) + local tool = (raw and raw.toolName) or "" + local cwd = (raw and raw.cwd) or "" + local args = raw and raw.toolArgs + + local canonical_tool = COPILOT_TOOL_MAP[tool] + + local args_string, args_table + if type(args) == "string" then + args_string = args + if canonical_tool ~= "ApplyPatch" then + local ok, decoded = pcall(vim.json.decode, args) + if ok and type(decoded) == "table" then args_table = decoded end + end + elseif type(args) == "table" then + args_table = args + if canonical_tool == "ApplyPatch" then + -- Unusual but possible in postToolUse: mirror the bash adapter which + -- stringified the object via tojson so downstream parsing is uniform. + args_string = vim.json.encode(args) + end + end + args_table = args_table or {} + + -- Defensive: the old bash shim explicitly skipped Edit/Write with an empty + -- file path and Bash with an empty command. Carry that contract forward — + -- otherwise a `{path: ""}` payload reaches diff.show_diff with file_path="" + -- and opens a broken diff tab. Drop the tool_name so the dispatcher no-ops. + local function blank(s) return s == nil or s == "" end + + local tool_input = {} + if canonical_tool == "ApplyPatch" then + if blank(args_string) then + return { tool_name = nil, cwd = cwd, tool_input = {} } + end + tool_input.patch_text = args_string + elseif canonical_tool == "Bash" then + if blank(args_table.command) then + return { tool_name = nil, cwd = cwd, tool_input = {} } + end + tool_input.command = args_table.command + elseif canonical_tool == "Edit" then + local fp = resolve_path(args_table.path, cwd) + if blank(fp) then + return { tool_name = nil, cwd = cwd, tool_input = {} } + end + tool_input.file_path = fp + tool_input.old_string = args_table.old_str or "" + tool_input.new_string = args_table.new_str or "" + tool_input.replace_all = false + elseif canonical_tool == "Write" then + local fp = resolve_path(args_table.path, cwd) + if blank(fp) then + return { tool_name = nil, cwd = cwd, tool_input = {} } + end + tool_input.file_path = fp + tool_input.content = args_table.file_text or args_table.content or "" + end + + return { + tool_name = canonical_tool, + cwd = cwd, + tool_input = tool_input, + } +end + M.normalisers = { claudecode = identity, opencode = opencode, - -- codex / copilot / gemini will land their own normalisers as they flip. + copilot = copilot, + -- codex / gemini will land their own normalisers as they flip. } --- @param raw table decoded hook payload diff --git a/tests/plugin/pre_tool_normaliser_spec.lua b/tests/plugin/pre_tool_normaliser_spec.lua index 65af96a..70ea38d 100644 --- a/tests/plugin/pre_tool_normaliser_spec.lua +++ b/tests/plugin/pre_tool_normaliser_spec.lua @@ -100,3 +100,149 @@ describe("normalisers.normalise (opencode)", function() assert.is_nil(out.tool_name) end) end) + +describe("normalisers.normalise (copilot)", function() + -- Copilot's hook payload is {toolName, cwd, toolArgs} where toolArgs is a + -- JSON-encoded string for preToolUse (an object for postToolUse). For + -- apply_patch the string is the raw patch text — not JSON. + + local function copilot_pre(tool_name, args_obj) + return { + toolName = tool_name, + cwd = "/proj", + toolArgs = vim.json.encode(args_obj), + } + end + + it("edit maps to canonical Edit with resolved absolute path", function() + local out = normalisers.normalise( + copilot_pre("edit", { path = "src/foo.lua", old_str = "a", new_str = "b" }), "copilot") + assert.are.same({ + tool_name = "Edit", + cwd = "/proj", + tool_input = { + file_path = "/proj/src/foo.lua", + old_string = "a", + new_string = "b", + replace_all = false, + }, + }, out) + end) + + it("str_replace aliases to Edit", function() + local out = normalisers.normalise( + copilot_pre("str_replace", { path = "/abs/x", old_str = "x", new_str = "y" }), "copilot") + assert.equals("Edit", out.tool_name) + assert.equals("/abs/x", out.tool_input.file_path) + assert.equals("x", out.tool_input.old_string) + assert.equals("y", out.tool_input.new_string) + end) + + it("create maps to Write with file_text → content", function() + local out = normalisers.normalise( + copilot_pre("create", { path = "/proj/new.lua", file_text = "hello" }), "copilot") + assert.equals("Write", out.tool_name) + assert.equals("/proj/new.lua", out.tool_input.file_path) + assert.equals("hello", out.tool_input.content) + end) + + it("write maps to Write and accepts content as fallback", function() + local out = normalisers.normalise( + copilot_pre("write", { path = "/proj/w.lua", content = "body" }), "copilot") + assert.equals("Write", out.tool_name) + assert.equals("body", out.tool_input.content) + end) + + it("bash maps to Bash with command", function() + local out = normalisers.normalise( + copilot_pre("bash", { command = "ls", description = "list" }), "copilot") + assert.equals("Bash", out.tool_name) + assert.equals("ls", out.tool_input.command) + end) + + it("apply_patch treats toolArgs string as raw patch text (not JSON)", function() + local out = normalisers.normalise({ + toolName = "apply_patch", + cwd = "/proj", + toolArgs = "*** Begin Patch\n*** End Patch\n", + }, "copilot") + assert.equals("ApplyPatch", out.tool_name) + assert.equals("*** Begin Patch\n*** End Patch\n", out.tool_input.patch_text) + end) + + it("apply_patch with object toolArgs (postToolUse shape) stringifies it", function() + -- Mirrors the bash adapter's `if type==string then . else tojson end`. + local out = normalisers.normalise({ + toolName = "apply_patch", + cwd = "/proj", + toolArgs = { some = "object" }, + }, "copilot") + assert.equals("ApplyPatch", out.tool_name) + assert.is_string(out.tool_input.patch_text) + end) + + it("resolves relative path against cwd", function() + local out = normalisers.normalise( + copilot_pre("edit", { path = "src/rel.lua", old_str = "a", new_str = "b" }), "copilot") + assert.equals("/proj/src/rel.lua", out.tool_input.file_path) + end) + + it("noise / unknown tool yields nil tool_name", function() + local out = normalisers.normalise( + copilot_pre("view", { path = "/tmp/whatever" }), "copilot") + assert.is_nil(out.tool_name) + end) + + it("malformed JSON toolArgs is treated as empty args (no raise)", function() + -- E2E regression: Copilot must never send an empty-path Edit downstream. + -- The defensive blank-path branch drops tool_name to nil so the dispatcher + -- no-ops — matches the old bash shim's `if [[ -z "$FP" ]]; then exit 0`. + local out = normalisers.normalise({ + toolName = "edit", + cwd = "/proj", + toolArgs = "}{not json", + }, "copilot") + assert.is_nil(out.tool_name) + end) + + it("Edit with explicit empty path drops tool_name (matches old bash guard)", function() + -- {"toolName":"edit","toolArgs":'{"path":"","old_str":"a","new_str":"b"}'} + -- The old shim exited 0 on empty $FP. Without this defensive branch the + -- payload would reach diff.show_diff with file_path="" and open a broken + -- diff tab. Keep parity by nilling tool_name. + local out = normalisers.normalise( + copilot_pre("edit", { path = "", old_str = "a", new_str = "b" }), "copilot") + assert.is_nil(out.tool_name) + end) + + it("Write with explicit empty path drops tool_name", function() + local out = normalisers.normalise( + copilot_pre("create", { path = "", file_text = "x" }), "copilot") + assert.is_nil(out.tool_name) + end) + + it("Bash with empty command drops tool_name", function() + local out = normalisers.normalise( + copilot_pre("bash", { command = "", description = "noop" }), "copilot") + assert.is_nil(out.tool_name) + end) + + it("apply_patch with empty toolArgs string drops tool_name", function() + local out = normalisers.normalise({ + toolName = "apply_patch", + cwd = "/proj", + toolArgs = "", + }, "copilot") + assert.is_nil(out.tool_name) + end) + + it("postToolUse object toolArgs is used directly", function() + local out = normalisers.normalise({ + toolName = "edit", + cwd = "/proj", + toolArgs = { path = "/proj/p.lua", old_str = "a", new_str = "b" }, + }, "copilot") + assert.equals("Edit", out.tool_name) + assert.equals("/proj/p.lua", out.tool_input.file_path) + end) +end) From 66bb071495c37ec437397b5269d4475a7ff52155 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 22 May 2026 01:58:31 +0530 Subject: [PATCH 2/2] docs: refresh copilot test docstrings + path-normalisation note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the E2E test headers in tests/backends/copilot/* to point at the in-process Lua flow (pre_tool.handle / post_tool.handle / apply/patch.lua) instead of the retired bin/core-{pre,post}-tool.sh path. Also reframes the "regression fixed in core-post-tool.sh" comment around the patch-paths loop in post_tool.lua that the test actually guards now. Adds a note in normalisers.lua (copilot block) explaining that paths are collapsed through vim.fs.normalize — the old bash copilot shim preserved paths verbatim, so this is a deliberate behavioural change to match the canonical-key invariant opencode already follows. Co-Authored-By: Claude Opus 4.7 --- lua/code-preview/pre_tool/normalisers.lua | 6 ++++++ tests/backends/copilot/test_apply_patch.sh | 10 ++++++---- tests/backends/copilot/test_edit.sh | 5 +++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/lua/code-preview/pre_tool/normalisers.lua b/lua/code-preview/pre_tool/normalisers.lua index 13e20e6..371f721 100644 --- a/lua/code-preview/pre_tool/normalisers.lua +++ b/lua/code-preview/pre_tool/normalisers.lua @@ -101,6 +101,12 @@ local COPILOT_TOOL_MAP = { -- an object in postToolUse. For apply_patch the string IS the raw patch text -- (not JSON). For every other tool the string contains a JSON object with -- snake_case keys (path, old_str, new_str, file_text, command, ...). +-- +-- Note: file paths are run through the shared `resolve_path`, which collapses +-- ../ and ./ segments via vim.fs.normalize. The old bash copilot shim did +-- not — paths were preserved verbatim. The change is deliberate and matches +-- opencode's contract: internal keys (active_diffs, changes registry) must +-- be canonical so logically-same files compare equal across backends. local function copilot(raw) local tool = (raw and raw.toolName) or "" local cwd = (raw and raw.cwd) or "" diff --git a/tests/backends/copilot/test_apply_patch.sh b/tests/backends/copilot/test_apply_patch.sh index 641ad8c..140f655 100644 --- a/tests/backends/copilot/test_apply_patch.sh +++ b/tests/backends/copilot/test_apply_patch.sh @@ -3,15 +3,16 @@ # # Drives the full pipeline for GPT-style apply_patch tool calls: # raw patch text as toolArgs → backends/copilot/code-preview-diff.sh -# → bin/core-pre-tool.sh → bin/apply-patch.lua +# → nvim_call → lua/code-preview/pre_tool/init.lua +# → lua/code-preview/apply/patch.lua # → Neovim diff previews for all files in the patch # And the mirror post path: # → backends/copilot/code-close-diff.sh -# → bin/core-post-tool.sh +# → nvim_call → lua/code-preview/post_tool.lua # → close_for_file for every Update/Add/Delete directive. # # Distinct from tests/backends/opencode/test_apply_patch.sh, which exercises -# only bin/apply-patch.lua in isolation (parser-level). +# the patch parser in isolation. COPILOT_PRE="$REPO_ROOT/backends/copilot/code-preview-diff.sh" COPILOT_POST="$REPO_ROOT/backends/copilot/code-close-diff.sh" @@ -169,7 +170,8 @@ tail")" assert_eq "true" "$(nvim_eval "require('code-preview.diff').is_open()")" \ "diff should be open during mixed patch" || return 1 - # Post-hook must close ALL four — the regression fixed in bin/core-post-tool.sh. + # Post-hook must close ALL four — regression guard for the patch-paths loop + # in lua/code-preview/post_tool.lua (one close_for_file per patched file). run_copilot_post_patch "$patch" sleep 0.6 diff --git a/tests/backends/copilot/test_edit.sh b/tests/backends/copilot/test_edit.sh index ea99049..36814af 100644 --- a/tests/backends/copilot/test_edit.sh +++ b/tests/backends/copilot/test_edit.sh @@ -6,8 +6,9 @@ # then verifies Neovim state via RPC. # # Copilot quirk: toolArgs is a stringified JSON object for most tools, and the -# raw patch text for apply_patch. The adapter normalizes both to the shared -# {tool_name, cwd, tool_input} format consumed by bin/core-pre-tool.sh. +# raw patch text for apply_patch. The `copilot` entry of +# lua/code-preview/pre_tool/normalisers.lua maps both into the canonical +# {tool_name, cwd, tool_input} shape consumed by pre_tool.handle(). COPILOT_PRE="$REPO_ROOT/backends/copilot/code-preview-diff.sh" COPILOT_POST="$REPO_ROOT/backends/copilot/code-close-diff.sh"