Repository navigation
fix: open no early edit row when the edit's read goes to the host - #107
Conversation
Cursor's edit sends a private read of its target under the edit's call
id before the write. For an existing file outside the workspace (by real
path, including through a workspace symlink), or one the provider cannot
read, that read goes to OpenCode's permission-aware `read`. It arrived
as the call's first exec and closed the early row as a failed edit
("Cursor did not finish this tool call."), and the real edit followed in
the next step.
The exec handler and the early row now decide from one predicate,
`cursorEditReadAnswer`, and no row opens when the read reaches the host.
Reads the provider still answers itself keep their rows: workspace
files, new files, and dangling symlinks, which missing-target validation
answers.
An empty existing file showed the same failed row inside the workspace:
its whole-file write stays a `write`, but `edit` was predicted. It is now
predicted like a creation.
Fixes #106
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
OpenCode 2 removes edit and write for eligible GPT models, so its catalog is {read, patch}, not {read, patch, write}. Also cover a workspace file without a final newline.
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryThis PR avoids opening an early edit row when OpenCode must answer the file’s private read.
No actionable issues were found. Tests and live hosts were not run during this review. The full PR diff was reviewed because the previous and current heads have different merge bases with the supplied target commit. No previous Greptile threads were supplied.
|
…d parallel steps Run every early-row case on native and GPT catalogs of both hosts, including a switch either way, where the epoch keeps every tool and only the permitted set names the file tool. Add CRLF per catalog, creations written without a read, and a parallel step that holds after the host read, in either order.
|
Retested 26fd0c4 (b35def6 only adds tests) with our Cursor stub on OpenCode 2.0.31 (an outside file, a workspace symlink to an outside file, an unreadable workspace file, an empty workspace file and a normal one, on the native and GPT |
A rewrite with unchanged content has no text to replace, so on native
catalogs its whole-file write stays a `write` after the row opened as
`edit`. The row closed as failed ("Cursor did not finish this tool
call.") right before the host `write` of the same call.
The row cannot be renamed once started (OpenCode 2 rejects a renamed
call), both hosts reject an `edit` with identical strings, and Cursor CLI
still runs approval and the write for unchanged content, so the host
write stays. When the claimed exec continues the same Cursor call as a
different host tool, the row now completes with a note naming that tool.
Rows whose call never ran, or that the provider refused, still close as
unfinished.
|
@nkoynov ready for a retest |
|
Retested 37b0d9b on the same stub set, plus an unchanged whole-file rewrite of a workspace file. Main cf5822d showed 7 failed early rows, and the PR showed none: the rewrite's |
Fixes #106.
Problem
Since #94, an edit of an existing file outside the workspace shows a failed
editrow ("Cursor did not finish this tool call.") right before areadof that file. The real edit runs in the next step, so nothing is lost, but every such edit leaves a failed call in the client and in history. The reporter saw it on 193 of 530 edits in one day.Cursor's edit first sends a private read of its target under the edit's call id.
recoverCorrelatedEditReadanswers that read itself for workspace files, but leaves it to OpenCode's permission-awarereadwhen the file's real path is outside the workspace. The early row is opened onpartial_tool_callfromhostToolForCursorEdit, which has no containment check. So it predictededit, the first exec was aread, and the row closed as failed.The same happens for a workspace file the provider cannot read, and for a workspace symlink pointing outside.
Fix
cursorEditReadAnswer(src/protocol/tools.ts) is now the one place that decides who answers the edit's read:empty(missing target),complete(readable workspace file),external(containment not proven by real path), orhost(not a regular file, too large, unreadable).recoverCorrelatedEditReaduses it instead of its own copy of the check, with the same outcomes as before.cursorEditReadReachesHostadds the missing-target validation that runs next, so a dangling symlink or directory, whose read the provider still answers, keeps its row.Similar case found by the tests
An edit of an empty existing file showed the same failed row, inside the workspace. Its whole-file write has no text for an
editto replace, so it stays awrite, buthostToolForCursorEditpredictededit. It is now predicted like a creation (write, else the patch tool). GPT catalogs were not affected: witheditremoved, the old prediction was already the patch tool.Tests
test/early-tool-rows.test.ts, 130 tests. Every pump case runs on eight catalogs: nativeedit/writeand GPT (apply_patchon 1.x,patchon 2.0, whereedit/writeare removed) on each host, plus a model switch in each direction, where the epoch catalog keeps every tool and onlypermittedToolNamesnames this turn's file tool.cursorEditReadAnswer/cursorEditReadReachesHost: workspace, empty, symlinked root, missing inside/outside, outside, workspace parent, symlink out, symlink in, dangling symlink, directory, file over 50 MB (sparse), path through a file, unreadable.partial_tool_call→tool_call_started→read_args) for outside files, workspace symlinks to outside files and unreadable workspace files. No early row, no failed call, one hostread, and the samecorrelatedEditCallIddecision on the exec.edit,write, or the patch tool's Update/Add File). CRLF on 2.0patchopens no row and makes no call, since that conversion refuses the edit.writeor the patch tool.tool_requests_listedholding the step open, an outside edit and an inside edit, in either order. One hostread, one bound mutation row, no failed row, one finish.editrow namingwrite, then the hostwriteruns; GPT binds the patch tool), and a host read of another file under the edit's call id. The never-ran case still reports "Cursor did not finish this tool call."workspace_paths, which only worked because the root fell back toprocess.cwd()with no containment check. They now set the workspace they meant.On
main, every outside-file and symlink case fails on all catalogs. Under Bun on macOS,realpathneeds read access to the file, so an unreadable file is classifiedexternalthere andhoston Linux; either way its read goes to the host, and the test follows the runtime.A call that runs as a different tool
When Cursor rewrites a file with identical content,
planWholeFileEdithas nothing to replace and the write stays awrite. On native catalogs, including after a switch back from GPT, the row opened aseditand closed as failed right before the hostwriteof the same call. GPT catalogs bind it as a patch Add File.The content isn't known when the edit is announced, and the obvious fixes don't hold:
Tool call name changedinpublish-llm-event.ts, v2.0.31), so the row can't becomewrite.editwith identical strings ("No changes to apply").edit/external_directorypermission. Cursor CLI'sLocalWriteExecutordoesn't skip unchanged content either: it runsshouldBlockWrite, approval and the write.So the host write stays. When the claimed exec continues the same Cursor call as another host tool, the row completes with "Cursor continued this call as
write." (earlyRowHandoff) instead of "Cursor did not finish this tool call." Rows whose call never ran, or that the provider refused, still close as unfinished.Verification
bun run typecheck,bun run build,bun teston macOS with Bun 1.4.3: 2485 pass, 0 fail. An earlier revision had one uncaptured failure in one of five full runs, not reproduced since.test/early-tool-rows.test.tsinoven/bun:1.4.3on Linux as a non-root user: 130 pass, none skipped. A Linuxbun test --coveragerun covers all 42 changed source lines.The 0.8.3 changelog line for early rows (#73) now covers this, since 0.8.3 is not released yet. Based on @nkoynov's fix in nkoynov/cursor-opencode-provider@608a576 and bc7cb5b.