Repository navigation
fix(diff): keep Neovim 0.8 and 0.9 alive when a handler raises during an openDiff setup - #574
Conversation
… an openDiff setup A LuaJIT older than its commit e86990f7f (Neovim 0.8 and 0.9) keeps pointing at the main Lua thread when a Neovim API call runs a Lua callback and then raises into a pcall inside a coroutine. The next exit from a compiled trace corrupts that thread's stack and Neovim aborts. utils.resume_tool_coroutine resumes the coroutine of a tool with the JIT off and its traces flushed on such a LuaJIT, and gives the JIT back afterwards. handle_invoke and the answer to a blocking diff both use it. The API call before the rethrow in _setup_blocking_diff (#538) is gone: it fixed one case and left the others. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
A jit table with no version string made claudecode.utils fail to load. tostring turns a missing or non-string version into a string that the check cannot read, and such a version counts as affected like the others. The comment above the check now cites LuaJIT issue 1066 and its fix commit, and says that we did not read the LuaJIT source for other code that uses cur_L. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ <!-- xum-attribution: model=anthropic:claude-sonnet-5-5 thinking=xhigh -->
The fact matched the resume line anywhere in the block, comment lines included. So a plain coroutine.resume plus a comment that names utils.resume_tool_coroutine kept the fact TRUE and check.sh passed. The pattern now needs the assignment line of the code. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ <!-- xum-attribution: model=anthropic:claude-sonnet-5-5 thinking=xhigh -->
The README named Neovim versions, but the code decides by the LuaJIT version string. It now names the condition (a LuaJIT build older than 2023-09-21, or an unreadable version) and says what the plugin does: it turns the JIT off and flushes the traces before it resumes a tool's coroutine, and gives the JIT back as it was. The spec comments cite LuaJIT issue 1066 for the crash and drop the claims about the cause that no source shows. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ <!-- xum-attribution: model=anthropic:claude-sonnet-5-5 thinking=xhigh -->
…t-guard covered.lock: took main's version. The next commit records it again.
… merging main --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ <!-- xum-attribution: model=anthropic:claude-sonnet-5-5 thinking=xhigh -->
The comment said that the three earlier cases hit the bug in a few of 100 runs without the guard. Without the guard, they ended with a plenary summary in 100 of 100 runs each on v0.8.0 and on v0.9.5. Only the eager case shows the bug, and the comment now says so. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ <!-- xum-attribution: model=anthropic:claude-sonnet-5-5 thinking=xhigh -->
…t-guard covered.lock: took main's version. The next commit records it again.
… merging main --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ <!-- xum-attribution: model=anthropic:claude-sonnet-5-5 thinking=xhigh -->
|
@codex review |
|
@codex security review |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
covered.lock: took main's version. check.sh --record wrote it again for the merged diff.lua.
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…oder#577) ## Why In the `vertical` and `horizontal` layouts, a proposal whose line breaks are all CRLF was loaded into the proposal buffer with `'fileformat'` `unix` and a CR at the end of each line. The save joined the lines with LF. So a line that the user typed, or the join of two lines, came back with LF, or with a CR in the middle of a line, where Neovim's `:w` writes CRLF. A CRLF proposal that equals a CRLF file also showed every line as changed, because the file's own buffer is `dos` and holds its lines without CRs. I found it while verifying coder#551. ## What changed 1. `_setup_blocking_diff` (`lua/claudecode/diff.lua`) loads a proposal as Neovim loads a file. When `'fileformats'` includes `dos`, and the proposal has a line break and only CRLF line breaks, the buffer gets `'fileformat'` `dos` and lines without their CRs. Any other proposal gets `'fileformat'` `unix` and is split at LF, as before. The diff sets `'fileformat'` in both cases, because a new buffer takes the first entry of `'fileformats'`, and that entry can be `dos`. 2. `_resolve_diff_as_saved` joins the lines with the line break of the buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), and adds the final one when `'eol'` is on. Before, it always used LF. So `:setlocal fileformat=dos` on an LF proposal now answers CRLF, as `:w` writes it. 3. The `unified` layout does not change. Its buffer is read-only, and its answer is the proposal. 4. `lua/claudecode/README.md` states both rules, and that one empty line is saved as one line break of the buffer's `'fileformat'`. 5. `tests/integration/diff_accept_crlf_spec.lua` is new (159 tests). `tests/mocks/vim.lua` gives the mock `vim.o` the default `fileformats = "unix,dos"`, because the unit specs of the diff now read it. 6. `covered.lock` of `verify-diff-lifecycle` records the new hashes of `diff.lua` (the file, `resolve_saved` and `setup_blocking`). ## What a user sees 1. A line that the user types into an accepted CRLF proposal, or two lines that the user joins, comes back with CRLF, as `:w` writes it. For the proposal `"a\r\nb\r\n"` and a typed line after the last line, main answers `"a\r\nb\r\nx\n"` and this PR answers `"a\r\nb\r\nx\r\n"`. 2. A CRLF proposal that equals the CRLF file shows no difference. Main marked every line. 3. An unedited proposal still comes back as exactly the proposal, in every layout, under every `'fileformats'` value that I ran. 4. Nothing else changes for the default `'fileformats'` (`unix,dos`): the controls of the spec (an LF proposal, a proposal with CRLF and LF line breaks, a lone CR, no line break, unedited, emptied) give the same bytes on main and here. 5. `'fileformats'` `unix` (no `dos`) keeps a CRLF proposal as `unix` with its CRs, as the file's own buffer does. The equal-text view shows no difference there, and a typed line gets LF, as `:w` writes it. ## Verification The spec compares the answer with the bytes that Neovim's own `:w` writes for the same text after the same commands (an oracle that reads the text as a file under the same `'fileformats'`), or with an exact expected text. 1. Red, then green, run on Neovim 0.12.3, 0.11.5, 0.10.4, 0.9.5 and 0.8.0 (all five give the same counts): 159 tests pass with the fix. With `origin/main`'s `diff.lua`, 105 pass and 54 fail. The unified cases run on 0.8.0 too (coder#564). 2. Fourteen mutants of the changed lines are each red on Neovim 0.12.3 (the last seven also on 0.8.0, with the same counts). Failed tests per mutant: - Split and join: the CRs stay in the lines 70, the join always uses LF 66, the final break is always LF 56, `mac` is ignored 2, dos for any text with a CRLF 12, dos for a text with no line break 2, dos even with a lone LF 32. - `'fileformats'` check: no check (always dos) 8, an exact match of `unix,dos` or `dos,unix` 4, dos for any value except `unix` 4, dos only when it is the first entry 42, `'fileformat'` always `unix` 64, always `dos` 48, set only for `dos` 12. 3. A probe of 1860 cases per run on Neovim 0.12.3 and 0.8.0 (three layouts, four old files, 15 proposals, 15 edits) compares each answer with the bytes of a fresh `nvim --clean` that reads the proposal as a file under the same `'fileformats'`, runs the same commands and writes with `:w`. The columns give mismatches. The two Neovim versions give the same numbers. | `'fileformats'` | main | this PR | unedited answers that differ from the proposal (main / this PR) | |---|---|---|---| | `unix,dos` | 748 | 144 | 0 / 0 | | `dos,unix` | 788 | 184 | 0 / 0 | | `unix` | 304 | 192 | 0 / 0 | | `dos` | 1104 | 516 | 0 / 0 | | `unix,mac` | 608 | 544 | 0 / 0 | Every mismatch that remains is one of the items under "Not fixed here", or a fold (see below). Cleanup leaves no state, buffer, window or tab behind in any case. Under `unix,dos`, `dos,unix` and `unix`, the equal-text view marks 0 lines in every case. 4. The only cases where this PR has a mismatch that main does not have are `dup_line` and `dd_first` (`yyp` and `dd` on line one) against an old file that equals the proposal, for CRLF proposals: 28 cases for each of `unix,dos`, `dos,unix` and `dos`. Diff mode folds every line of an equal text, so those commands act on the whole fold. Main does the same for an equal LF text (`same|lf|dup_line`), and showed every line of an equal CRLF text as changed, so it did not fold. This follows from item 2 of "What a user sees". 5. An independent verifier ran its own probe (2604 cases per run) under `unix,dos`, `dos,unix`, `unix` and `dos` on all five Neovim versions, and under `unix,dos,mac`, `unix,mac` and `dos,mac` on 0.12.3. In every run, 0 unedited answers differ from the proposal. ## Not fixed here These items are tracked in coder#581. All of them exist on `origin/main` already. An independent probe that compares each answer with `:w` of the same buffer found this PR never worse than main. 1. A CR inside a line, then `:setlocal ff=mac`. `:w` writes that CR as NL, and the save keeps the CR. The proposal `"a\rb\n"` gives `"a\rb\r"`, and `:w` gives `"a\nb\r"`. Main joined with LF, which was also wrong. 2. With `'fileformats'` `unix,mac`, Neovim reads a text such as `"a\r\nb\r\r\n"` as `mac`. The diff never detects `mac` (main neither). In 24 cases (an `ff=mac` edit only), main's answer equals the file-read oracle and this PR's answer does not, because main ignores `'fileformat'` when it joins. Against `:w` of the same buffer, both are wrong in these 24 cases. 3. An empty proposal, then typed lines. `'eol'` stays off (coder#542), so `Gox` gives `"\nx"`, where `:w` of an empty file gives `"\nx\n"`. 4. `'fileformats'` that starts with `dos`, and a proposal with no LF (`""`, `"a"`, `"a\r"`). Neovim reads it as `dos`. The diff keeps `unix`, so a typed line gets LF where `:w` writes CRLF. 5. `'fileformats'` with the single entry `dos`. Neovim then reads every file as `dos`, an LF file too. An LF or mixed proposal stays `unix`, so a typed line gets LF where `:w` of the file writes CRLF. Neovim reads the equal file as `dos`, so the equal-text view still marks lines for a text that is not all CRLF: this PR marks 2 of the 15 probe texts (a mixed text, and one with a leading lone LF), and main marks 11 of 15. ## Gates The head is `1c7b5adcfc165963ffc250ab9ebeedad00ddf954`. It merges `origin/main` at `7dbad04b`. Main moved three times while this PR was in progress, and each time only `covered.lock` conflicted. The gates below ran in full at `4525dad1` (the merge with `origin/main` at `c43b6a30`). The last merge brought coder#574. At the head I ran the checks of item 6, and CI runs the rest on this head. 1. `mise run all` (`BUSTED_SEED=97531`) exits 0: 1837 successes, 0 failures, 0 errors, 34 pending. luacheck: 0 warnings, 0 errors in 194 files. 2. `mise run format-check` exits 0. 3. `scripts/run_integration_tests_individually.sh` exits 0 on Neovim 0.12.3 (Total Success 828, Failed 0, Errors 0) and on Neovim 0.8.0 (Total Success 810, Failed 0, Errors 0). The new spec alone gives 159 passes on Neovim 0.12.3, 0.11.5, 0.10.4, 0.9.5 and 0.8.0. 4. `check.sh --hash-only` of the four `verify-*` skills exits 0. The full `check.sh` of `verify-diff-lifecycle` exits 0 (`main` 93477 distinct states, `live-DisconnectedCallsAnswered` 5557). The merge with main conflicted in `covered.lock` only. I took my side, ran the full `check.sh` on the merged tree (all 23 facts TRUE, the model passes), and then ran `--record`. The last merge moved only `file` hashes (`diff.lua`, `init.lua`, `selection.lua`) and no block. Against `origin/main`, the lock moves 3 lines: the file hash of `diff.lua`, and the blocks `resolve_saved` and `setup_blocking`. No guard moved. The change decides which bytes the accepted text holds. It does not change a diff's status, who gets its answer, or when. 5. At the head, after the last merge: the full `check.sh` of `verify-diff-lifecycle` (all 23 facts TRUE, `main` 93477, `live-DisconnectedCallsAnswered` 5557) and then `--record`. coder#574 changed the blocks `open_blocking`, `handle_invoke` and `setup_blocking` on main, and the lock of this PR moves only the 3 lines of this PR against `origin/main` (the file hash of `diff.lua`, `resolve_saved`, `setup_blocking`). 6. At the head: `format-check` exits 0, `check.sh --hash-only` of the four skills exits 0, the new spec gives 159 passes on Neovim 0.12.3 and 0.8.0, and the 44 unit specs of the diff, tools and server modules give 752 successes, 0 failures. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
Why
The goal of this series: land the behavior fixes and a code-shape refactor of this plugin on main in small PRs, fixes first. This PR is one fix.
On Neovim 0.8 and 0.9, a Lua autocmd handler of the user can make Neovim exit at once (exit 134 or 139) while the plugin opens or closes the windows of an
openDiffdiff. A user can lose the unsaved changes of open buffers when that happens. #538 fixed one case. Other cases still abort.The cause, in three sentences. Neovim 0.8 and 0.9 bundle a LuaJIT older than its commit e86990f7f of 2023-09-21, which fixes LuaJIT issue 1066: after a C function runs Lua on another thread and then raises into a
pcall,cur_Lstill names that thread, and the next exit from a compiled trace crashes (the issue shows a crash inlj_trace_exit). A Neovim API call in a coroutine that runs a Lua callback and then raises behaves like that C function, and Neovim aborts (measured, below). The plugin runsopenDiffin a coroutine, and Neovim runs the handlers of the user while the plugin opens and closes its windows.What a source shows: the issue text and the patch of e86990f7f, which sets
cur_Lagain inerr_unwind(src/lj_err.c, caseFRAME_PCALL). We did not read the LuaJIT source for other code that usescur_L. The proof below measures what happens instead.What changed
utils.resume_tool_coroutine(co, ...)is new. On an affected LuaJIT it turns the JIT off and flushes the compiled traces before it resumes the coroutine, and it gives the JIT back as it was when the resume returns. Everywhere else it is a plaincoroutine.resume. It returns exactly whatcoroutine.resumereturns,nilvalues and their count included.jitexists andjit.versionis not a rolling2.1.<n>release withnof 1695261248 or more.LuaJIT 2.1.0-beta3(Neovim v0.8.0) andLuaJIT 2.1.1692716794(Neovim v0.9.5) are affected. A version that is no string, or that the check cannot read, counts as affected, which costs only a flush.handle_invokeand the answer to a blocking diff (open_diff_blocking) resume the tool's coroutine through it.git grep -n 'coroutine\.resume' -- lua pluginshows one code line, inside the helper.nvim_get_current_tabpage()before the rethrow in_setup_blocking_diff) and its comment are gone. The cases of fix(diff): keep Neovim alive when a handler raises during a failed openDiff setup #538's spec stay, and their assertions are unchanged. Only the comment above the eager-JIT case changed..agents/skills/verify-diff-lifecycle/check.sh: the factSetupAnswerReturnednow needs the assignment linelocal ... = utils.resume_tool_coroutine(co, result). A comment on a line of its own cannot satisfy it. A comment at the end of a plain-resume line leaves the fact TRUE, andcheck.shthen exits 3 (stale). The jobcovered-lockruns everycheck.shand fails on any non-zero exit, exit 3 included (.github/workflows/test.yml, the loop withstatus=1). The model is unchanged.lua/claudecode/tools/README.mdnames the helper, the LuaJIT condition and the cost, andlua/claudecode/README.mdlists the helper. The comment above the helper inutils.luanames the LuaJIT builds and the Neovim versions that the bug affects.Why #538's call goes away
#538's call is on main in every run below, and Neovim still died. The probe runs the tool path (
tools.handle_invoke) with 8 raising handlers, 2 passes, and an eager JIT. Two verifiers measured it on two main commits. Each cell counts the runs that killed Neovim, of 30. The counts depend on the host and its load: the same probe on main gave 13 of 30 in one run and 8 of 30 twice on v0.9.5.884f7e22(round 1 verifier)523ee200(round 2 verifier)Source: the two verifier reports (
verify-f55-r1-opus.mdandverify-f55-r2-opus.md), kept outside the repository.In the round 1 verifier's runs on main
884f7e22, a handler onWinEntermakes every setup fail and killed Neovim in 17 of 20 runs on v0.8.0 and in 17 of 20 on v0.9.5. Handlers onWinClosed,BufWinLeave,BufHidden,BufUnload,BufWipeoutand a mix of them, which the plugin's ownpcallswallows while the setup goes on, killed Neovim in 0 of 20 runs on both versions. The new spec case for a setup that succeeds therefore guards that the answers ofopenDiffstay as they are. It does not show the bug.Proof
Evidence folder: local files, not in the repository (
evidence/round1/). File names below are relative to it. The facts below are the ones that a verifier reproduced, or that I measured on the head named in the file.The eager case shows the bug and the guard removes it. With the helper forced to a plain
coroutine.resume, the eager case ofdiff_failed_setup_guards_spec.luaalone ended without a plenary summary in 6 of 10 runs on v0.8.0 and 8 of 10 on v0.9.5 (round 2 verifier, all with exit 134bad stack slot), and in 9 of 10 and 6 of 10 (round 1 verifier). The three other cases of that file kept their summary in 10 of 10 runs on each version (round 2 verifier). With the guard, the head had 0 of 10 without a summary in round 1.Each part of the fix has a test that turns red without it (a scratch mutant per row, never committed). The two verifiers ran the mutants again. "Unit" is
utils_resume_tool_coroutine_spec.luaandtool_coroutine_resume_spec.lua. "Spec" istests/integration/diff_tool_coroutine_jit_spec.luaon v0.8.0 and v0.9.5. The unmutated tree is green everywhere. Source:R1_mutants_matrix.txtandR1_mutants_failed_tests.txt.jit.off()jit.flush()jit.on())tostringremoved from the version checkThe verifiers also showed that with a plain
coroutine.resume(co, result)at the diff answer,SetupAnswerReturnedgoes FALSE andcheck.shexits 1.Nothing else changes for a user. One
openDiffaccept (:w) and one reject (:q) through the WebSocket, with.agents/skills/verify-claudecode-nvim, on main and on this PR, on v0.8.0 and 0.12.3: the answers (FILE_SAVEDplus the proposal text,DIFF_REJECTEDplus the tab name,TAB_CLOSED) and the window and buffer states (names, diff mode, text) are byte-equal in 10 of 10 files on each version. The round 2 verifier reproduced this. Source:R1_vcn_accept_reject_main_vs_head.txt,vcn/.Stability under load. Whole-file runs of
diff_tool_coroutine_jit_spec.luaanddiff_failed_setup_guards_spec.lua, one Neovim per run, pinned to one core with one busy loop on the same core. Runs that failed or ended without a summary: 0 of 100 on v0.8.0, 0 of 100 on 0.12.3 (both verifiers) and 0 of 30 on v0.9.5 (round 1 verifier). My own runs gave the same (R1_load_counts.txt). No run used a retry.Gates. On this head (
579df505d800f11fa019607b99b219dbe3a52d6e, which merges mainc43b6a308ea97f9dcfb0844ce6770e7ce7699ec0):covered.lockwas the only conflict of that merge. I took main's file and rancheck.sh --record, and the merge commit holds the recorded file.bash .agents/skills/verify-diff-lifecycle/check.shends== RESULT: PASS.mise run format-check: 438 files, 0 changed.mise run check: exit 0, and luacheck reports 0 warnings and 0 errors in 196 files.mise run test: 1858 successes, 0 failures, 0 errors, 34 pending. Four integration specs, once on 0.12.3 and once on v0.8.0, with 0 failures and 0 errors each time:diff_tool_coroutine_jit_spec.lua3 successes,diff_failed_setup_guards_spec.lua4, and the two specs that fix(diff): remove the legacy diff's temp file and its buffer when a step fails #516 added or changed,diff_legacy_cleanup_spec.lua18 anddiff_legacy_swap_spec.lua1. The whole integration runner did not run on this head (CI runs it). My change is the same as at230be2d2(the head that the round 2 verifier passed): the added and removed lines outsidecovered.lockare byte-equal, 469 lines in 10 files. On an earlier head (661e4e52, main48ca93bemerged), the integration runner gave 0.12.3Total Success: 603,Failed: 0,Errors: 0, and v0.8.0Total Success: 585,Failed: 0,Errors: 0. A first 0.12.3 run under a host load above 100 lost the summary of three files to the runner's 40 s limit (diff_close_save_spec.lua,diff_exit_reject_spec.lua,diff_left_window_spec.lua). Each passed alone (77, 28 and 33 successes), and the whole runner passed on the second run. The two new specs ran 20 times per version on v0.8.0, v0.9.5 and 0.12.3 on661e4e52with 0 failed runs and 0 runs without a summary. The round 2 verifier ran the gates on230be2d2:mise run test1758 successes, 0 failures, 0 errors, 29 pending, the integration runner 530/0/0 on 0.12.3 and 393/0/0 on v0.8.0, andcheck.shPASS. Its counts are lower than mine because main had fewer tests then. Sources:../round4/for this head,../round3/for the head before it, and../round2/R2_*.txtand../round2/m2_int_*.txtfor the older heads.Where the Neovim binaries come from (
R1_binary_provenance.txt, reproduced by the round 2 verifier).nvim-linux64.tar.gzof the official GitHub release v0.8.0 (published 2022-09-30), fetched withgh release download v0.8.0 -R neovim/neovim -p 'nvim-linux64.tar.gz*'. Its sha256 matches the.sha256sumfile and the binary that was run.--versionprintsNVIM v0.8.0-1210-gd367ed9b2.nvim-linux64.tar.gzof the official GitHub release v0.9.5 (published 2023-12-30), fetched the same way, with the same sha256 checks.Cost on Neovim 0.8 and 0.9
On Neovim 0.8 and 0.9, each tool call that runs in a coroutine (
openDiff) discards the compiled traces of the whole editor, and LuaJIT compiles them again when the code gets hot. The round 1 verifier measured it with about 740 compiled traces: a guarded resume took 18 to 41 µs (a plain resume took 1.1 µs or less), and the first run of a hot loop of 300 small functions after the flush took 3.6 to 10.3 ms against 0.3 to 0.6 ms when hot. Nobody measured a whole editing session. A LuaJIT that is not affected gets a plaincoroutine.resume.What this PR does not cover
_setup_blocking_diffdirectly is not covered. Such a spec must go throughtools.handle_invoke.scripts/manual_test_helper.luaresumes its own coroutine. It is a manual script and not part of the plugin.Generated with
xum• Model:anthropic:claude-sonnet-5-5• Thinking:xhigh