Skip to content

cursor: decode the native exec frame and stop reading tool calls as text - #179

Open
HectorBernstorff wants to merge 3 commits into
raine:mainfrom
HectorBernstorff:fix/cursor-native-tool-bridge
Open

HectorBernstorff wants to merge 3 commits into
raine:mainfrom
HectorBernstorff:fix/cursor-native-tool-bridge

Conversation

@HectorBernstorff

Copy link
Copy Markdown

Fixes #166.

The Cursor provider never emitted a tool_use, so any turn that needed a tool ended after one reply: the client saw the model's tool call as literal text (call_...\nfc_...) and the upstream's request to run the tool was dropped.

Root cause

Two field numbers in the hand-rolled protobuf decoder are wrong, and the frame that carries the tool request was never decoded at all:

field on the wire in the provider
AgentServerMessage.exec_server_message 2 3 (that is conversation_checkpoint_update)
InteractionUpdate.text_delta 1 2 (that is tool_call_started)
InteractionUpdate.thinking_delta 4 1
InteractionUpdate.turn_ended 14 3 (that is tool_call_completed)

AgentServerMessage also declared notes_session_id at tag 1, where the wire carries a varint id, so exec payloads failed to decode and the frames were skipped whole.

Two consequences, both visible in #166: tool_call_started (tag 2) was rendered as the model's text, and the native exec frame — where Cursor asks the client to run Read, Write or a shell command — was never read.

The field numbers are not guessed. They come from the protobuf descriptors shipped inside the installed Cursor Agent bundle (~/.local/share/cursor-agent/versions/<version>/index.js), and the frames were captured from the live upstream.

The fix

  • Correct the tags above.
  • Decode ExecServerMessage (id, exec_id, read_args, write_args, shell_args / shell_stream_args) into a new CursorStreamEvent::Exec.
  • Map the exec onto the Claude tools that fulfil it (Read, Write, Bash) and pause the stream with stop_reason: tool_use.
  • No pending tool is stored: the client's tool_result comes back inside the next request, where render_cursor_prompt already renders it as a <tool_result> block for the upstream, so the loop keeps going.

Verification

Notes

  • The existing readiness gating (allowed_tool_names, streaming, session id) is reused, so the bridge only engages when the client actually advertised those tools.
  • Two behaviours that look like bugs and are not: the Cursor model sometimes emits a wrong parameter name first (contents before content) and corrects itself on the following turn, and it probes with a Read before editing a file.

🤖 Generated with Claude Code

…text

The Cursor provider never produced a tool_use, so every turn that needed a tool
ended after one reply (issue raine#166). Two field numbers were wrong and the frame
that asks the client to run a tool was never decoded at all.

- AgentServerMessage.exec_server_message is tag 2, not 3 (tag 3 is
  conversation_checkpoint_update). The struct also declared a notes_session_id
  at tag 1, where the wire carries a varint id, so an exec payload was dropped
  whole.
- InteractionUpdate is 1 text_delta, 4 thinking_delta, 14 turn_ended. The old
  mapping read tag 2 (tool_call_started) as the model's text - which is why a
  tool call reached the client as the literal string "call_...\nfc_..." - and
  tag 3 (tool_call_completed) as usage.

With the exec frame decoded, read_args, write_args and shell_stream_args map
onto the Claude tools that fulfil them and the stream pauses with
stop_reason=tool_use. No pending tool is stored on purpose: the client's
tool_result travels back in the next request, where render_cursor_prompt already
turns it into a <tool_result> block for the upstream.

Field numbers come from the protobuf descriptors inside the installed Cursor
Agent bundle (index.js, 2026.10.01-e373342); the frames were captured from the
live upstream. Verified with the crate test suite (55 passed; two new tests
cover the exec decode and the text leak) and end to end through Claude Code,
where Read, Write and Bash all complete a real loop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@raine

raine commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Thanks for the fix and the live testing. I had an AI agent investigate the PR. Here's its assessment:

The corrected protobuf tags match the installed Cursor bundle and independent implementations. A schema-level wire test reproduced the old tool-ID text leak.

The review found these implementation issues:

  • WriteArgs.file_bytes is omitted. A byte-backed write decodes with empty file_text, which becomes an empty Claude Write. This affects byte-encoded text too, not just binary files.
  • Shell mapping drops working_directory and timeout. Read mapping drops offset and limit.
  • Native pauses insert registry state without a pending tool. Those entries aren't removed through normal resume handling and can hide a later XML pending tool in the same session.

There's also an unresolved runtime question: the PR starts a fresh Cursor run with rendered tool results instead of answering the exec on the original stream. The reported successful loops support its feasibility, but the transport waits for an end frame or 5 seconds of silence before delivering the tool call. Captures would help establish what happens if Cursor keeps sending heartbeats while waiting for a result.

These findings are based on source inspection and schema-level experiments, not execution of this PR's Rust provider. Recommended verification: decoder/bridge regression tests and a live read → edit existing file → shell → read-back loop, with the proxy and client running from different directories.

…context

Addresses the review on the first version:
- the run stays open across requests; each exec (read, write, shell) is
  answered on the same AgentService/Run stream with an ExecClientMessage that
  echoes id and exec_id, instead of starting a new run with rendered results
  (which made the agent re-issue the call it had just made);
- WriteArgs.file_bytes (tag 5) is honoured, shell working_directory/timeout and
  read offset/limit are carried into the Claude Code tool input;
- no pending-tool state is left behind: entries are taken on resume and swept
  when abandoned.

Also:
- one pooled reqwest client instead of one per request, with an HTTP/2
  keepalive interval (the timeout alone is inert in hyper);
- input tokens come from the checkpoint's token_details.used_tokens (the
  context of the latest step); turn_ended sums every internal step and showed
  a multiple of the real context on runs with tool calls.

cargo test, fmt and clippy are clean for the cursor provider.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@HectorBernstorff

Copy link
Copy Markdown
Author

Pushed f07965c. It answers each exec on the original Run stream (ExecClientMessage echoing id/exec_id), honours WriteArgs.file_bytes, carries shell working_directory/timeout and read offset/limit, and leaves no pending state behind. It also uses one pooled client with an HTTP/2 keepalive interval, and takes input tokens from the checkpoint's token_details.used_tokens, because turn_ended sums every internal step. cargo test, fmt and clippy are clean for the cursor provider.

- The prompt listed the client's tools but never said how to call them, so
  the agent tried MCP tools on its own channel and the server answered "Tool
  not available". The <tools> section now opens with the calling protocol:
  built-in tools for files and shell, and the <tool_use name>{json}</tool_use>
  form the bridge already parses for everything else.
- Read results reach Cursor as file text. Claude Code's Read answers in cat -n
  form, and the agent copied the line numbers back into files it wrote.
- tool_reference blocks (tool search) render as "Tool loaded".

cargo test, fmt and clippy are clean for the cursor provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HectorBernstorff

Copy link
Copy Markdown
Author

Pushed dbb4229. The prompt section now states the calling protocol for tools that are not the agent's own (without it the agent tried MCP tools on its own channel and got "Tool not available"). Read results reach Cursor as file text rather than cat -n output, and tool_reference blocks render as "Tool loaded".

This branch has not been deployed

No deployments
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.

Cursor: tool calls come back as plain text, so Claude Code turns end after one reply

2 participants