Skip to content

fix: open no early edit row when the edit's read goes to the host - #107

Merged
oakimov merged 5 commits into
mainfrom
fix/early-edit-row-host-read
Oct 18, 2026
Merged

oakimov merged 5 commits into
mainfrom
fix/early-edit-row-host-read

Conversation

@oakimov

@oakimov oakimov commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Fixes #106.

Problem

Since #94, an edit of an existing file outside the workspace shows a failed edit row ("Cursor did not finish this tool call.") right before a read of 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. recoverCorrelatedEditRead answers that read itself for workspace files, but leaves it to OpenCode's permission-aware read when the file's real path is outside the workspace. The early row is opened on partial_tool_call from hostToolForCursorEdit, which has no containment check. So it predicted edit, the first exec was a read, 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), or host (not a regular file, too large, unreadable). recoverCorrelatedEditRead uses it instead of its own copy of the check, with the same outcomes as before.
  • cursorEditReadReachesHost adds the missing-target validation that runs next, so a dangling symlink or directory, whose read the provider still answers, keeps its row.
  • No early row opens when the read reaches the host.

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 edit to replace, so it stays a write, but hostToolForCursorEdit predicted edit. It is now predicted like a creation (write, else the patch tool). GPT catalogs were not affected: with edit removed, the old prediction was already the patch tool.

Tests

test/early-tool-rows.test.ts, 130 tests. Every pump case runs on eight catalogs: native edit/write and GPT (apply_patch on 1.x, patch on 2.0, where edit/write are removed) on each host, plus a model switch in each direction, where the epoch catalog keeps every tool and only permittedToolNames names this turn's file tool.

  • Predicate matrix for 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.
  • Host reads (Edits outside the workspace show a failed early edit row before their read (since #94) #106): the issue's sequence (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 host read, and the same correlatedEditCallId decision on the exec.
  • Provider-answered reads: workspace, no final newline, CRLF, symlinked root, empty, new outside, dangling symlink. The row binds to the final tool (edit, write, or the patch tool's Update/Add File). CRLF on 2.0 patch opens no row and makes no call, since that conversion refuses the edit.
  • Creations written without a read, inside and outside the workspace: the row binds to write or the patch tool.
  • Parallel step: with tool_requests_listed holding the step open, an outside edit and an inside edit, in either order. One host read, one bound mutation row, no failed row, one finish.
  • Hand-off: an unchanged rewrite on all eight catalogs (native completes the edit row naming write, then the host write runs; 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."
  • Three existing tests opened rows for temp files without workspace_paths, which only worked because the root fell back to process.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, realpath needs read access to the file, so an unreadable file is classified external there and host on 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, planWholeFileEdit has nothing to replace and the write stays a write. On native catalogs, including after a switch back from GPT, the row opened as edit and closed as failed right before the host write of 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:

  • OpenCode 2 dies on a renamed call (Tool call name changed in publish-llm-event.ts, v2.0.31), so the row can't become write.
  • Both hosts reject an edit with identical strings ("No changes to apply").
  • Answering the write in the provider would skip OpenCode's edit/external_directory permission. Cursor CLI's LocalWriteExecutor doesn't skip unchanged content either: it runs shouldBlockWrite, 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 test on 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.ts in oven/bun:1.4.3 on Linux as a non-root user: 130 pass, none skipped. A Linux bun test --coverage run covers all 42 changed source lines.
  • No live host run of my own; @nkoynov's retest of 26fd0c4 on OpenCode 2.0.31 is in the comments. The change sits in the shared pump and applies to OpenCode 1.x and 2.0 alike. It doesn't touch RequestContext, checkpoints or what reaches Cursor, so prompt caching isn't affected.

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.

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

codecov Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

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.
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

This PR avoids opening an early edit row when OpenCode must answer the file’s private read.

  • Early edit rows open only when the provider answers the private read.
  • Edits of empty files predict a write row.
  • An early row finishes with a note when the call uses another tool.

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.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Cursor announces an edit] --> B{Private read reaches host?}
  B -->|Yes| C[Skip early row]
  B -->|No| D[Open predicted row]
  D --> E{Exec arrives}
  E -->|Predicted tool| F[Bind row to host call]
  E -->|Different tool| G[Complete row with handoff note]
  G --> H[Send actual host call]
  E -->|Missing or refused| I[Close row as unfinished]
Loading

Reviews (4) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile

…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.
@nkoynov

nkoynov commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

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 patch catalogs): main cf5822d showed 6 failed early rows, the PR none; the outside and unreadable cases get one host read, and the empty and normal workspace edits keep their row, which binds as write/patch and edit/patch. A real-key run on Opus 5.5 (two outside edits, one inside) gave 2 failed rows on main and 0 here, with all three edits landing. Typecheck, build and the suite pass (2391/0), and our fork's four tests for this pass on your code too.

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.
@oakimov

oakimov commented Oct 18, 2026

Copy link
Copy Markdown
Owner Author

@nkoynov ready for a retest

@nkoynov

nkoynov commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

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 edit row completes with "Cursor continued this call as write." before the host write (on GPT it binds as a patch), and the other cases behave as in my earlier comment. Typecheck, build and the full suite pass (2499/0), and our fork's four tests still pass on it.

@oakimov
oakimov merged commit 98f676c into main Oct 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Edits outside the workspace show a failed early edit row before their read (since #94)

2 participants