feat(ai): handle Cursor's modern exec wire protocol

Current Cursor CLI builds emit exec frames this client did not model. A
frame whose oneof number is absent from `agent.proto` decodes with
`message.case` unset, so the dispatcher found no handler, ran no tool and
sent no result — the server was left waiting on an execution that never
happened.

Every recognised frame now gets a typed answer:

- The seven Pi tools (45-51) run their local equivalents. They are a
  separate wire family from the legacy args, not aliases: `pi_grep`'s
  `ignore_case` is the inverse of the local `case` flag, `pi_find`
  searches filenames (so it routes to `glob`, not `grep`), and
  `pi_edit`'s replacements are renamed to snake_case pairs.
- Hooks, subagents, prechecks, MCP state, smart-mode, canvas,
  conversation search and agent-store answer with the error, not-found or
  empty-but-valid variant that is true of this client.
- Unnameable frames raise `ExecClientControlMessage.throw`
  (`unknown_exec_variant`); recognised frames with no truthful answer —
  `git_diff_request`, whose `GetDiffResponse` has no error variant —
  raise `exec_variant_unsupported`.

Four frames previously answered `create(XSchema, {})`. In proto3 that is
not an empty result: the oneof is unset and the server reads it as "the
tool ran and produced nothing", indistinguishable from success. They now
send real variants.

`connect_scm` lost its repository (the target rides in a oneof, so the
flat property was always undefined) and settled on a fixed failure at the
announcement, before the server's `success`/`error`/`rejected` verdict
arrived on the completion frame.

The stream decoder tracked a single "current" tool-call block and settled
it on any `toolCallCompleted`, ignoring the envelope `call_id`: an
unrelated completion paired the wrong block, and `start A, start B`
orphaned A so nothing ever paired it — which strips the whole interaction
from every rebuilt transcript. Blocks are now retained per envelope id.

`lsp` is advertised as MCP again; the native `diagnostics` frame covers
one of ~10 actions.

(cherry picked from commit 4d269724a3a448886d13b4323ac02aadbfe38de3)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 07:45:02 -03:00
committed by can1357
parent cdbe7c4ed2
commit b6e01c8a3c
15 changed files with 2997 additions and 59 deletions
@@ -244,6 +244,7 @@ function newBlockState(): BlockState {
return toolCall;
},
firstTokenTime: undefined,
openToolCalls: new Map<string, ToolCallState>(),
resolvedMcpToolCallIds: new Set<string>(),
setTextBlock: b => {
textBlock = b;
@@ -430,3 +431,96 @@ describe("CursorExecHandlers native delete gating (issue #5680)", () => {
expect(await Bun.file(movedTarget).exists()).toBe(false);
});
});
// The Pi frames (`ExecServerMessage` 45-51) are a separate wire family from the
// legacy `read`/`shell`/`grep` args, with different field names and different
// semantics. Each bridge handler therefore performs a real translation, and a
// wrong one silently searches the wrong thing instead of failing.
describe("CursorExecHandlers Pi frame translation", () => {
let cwd: string;
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-pi-test-"));
});
afterEach(async () => {
await removeWithRetries(cwd);
});
/** Captures the args one local tool was invoked with. */
function recordingHandlers(toolName: string): { handlers: CursorExecHandlers; calls: unknown[] } {
const calls: unknown[] = [];
const tool: AgentTool = {
name: toolName,
label: toolName,
description: "records its args",
parameters: type({}),
execute: async (_toolCallId: string, params: unknown) => {
calls.push(params);
return { content: [{ type: "text" as const, text: "ok" }] };
},
};
const handlers = new CursorExecHandlers({ cwd, tools: new Map([[toolName, tool]]) });
return { handlers, calls };
}
it("inverts pi_grep's ignore_case into the local tool's case-sensitivity flag", async () => {
// `ignore_case` and `case` are opposites. Passing the frame's value
// straight through would flip every search's matching.
const { handlers, calls } = recordingHandlers("grep");
await handlers.piGrep({ toolCallId: "c1", args: { pattern: "x", ignoreCase: true } } as never);
await handlers.piGrep({ toolCallId: "c2", args: { pattern: "x", ignoreCase: false } } as never);
expect(calls).toEqual([
{ pattern: "x", path: ".", case: false },
// Case-sensitive is the local default, so `false` maps to "unset",
// not to `case: true`.
{ pattern: "x", path: ".", case: undefined },
]);
});
it("folds pi_grep's separate glob onto the local tool's single path spec", async () => {
const { handlers, calls } = recordingHandlers("grep");
await handlers.piGrep({ toolCallId: "c1", args: { pattern: "x", path: "src", glob: "**/*.ts" } } as never);
await handlers.piGrep({ toolCallId: "c2", args: { pattern: "x", glob: "**/*.ts" } } as never);
expect((calls[0] as { path: string }).path).toBe("src/**/*.ts");
expect((calls[1] as { path: string }).path).toBe("./**/*.ts");
});
it("routes pi_find to glob, not grep, joining its pattern onto the path", async () => {
// `pi_find` searches filenames. Routing it to `grep` would search file
// contents for the glob text and return nothing.
const { handlers, calls } = recordingHandlers("glob");
await handlers.piFind({ toolCallId: "c1", args: { pattern: "*.ts", path: "src", limit: 10 } } as never);
await handlers.piFind({ toolCallId: "c2", args: { pattern: "*.ts", limit: 0 } } as never);
expect(calls).toEqual([
{ path: "src/*.ts", limit: 10 },
// A zero limit is protobuf's unset, not a request for zero results.
{ path: "*.ts", limit: undefined },
]);
});
it("renames pi_edit's camelCase replacements to the local tool's snake_case pairs", async () => {
const { handlers, calls } = recordingHandlers("edit");
await handlers.piEdit({
toolCallId: "c1",
args: { path: "a.ts", edits: [{ oldText: "before", newText: "after" }] },
} as never);
expect(calls[0]).toEqual({ path: "a.ts", edits: [{ old_text: "before", new_text: "after" }] });
});
it("lists directories for pi_ls through read, defaulting an empty path to cwd", async () => {
const { handlers, calls } = recordingHandlers("read");
await handlers.piLs({ toolCallId: "c1", args: { path: "" } } as never);
expect(calls[0]).toEqual({ path: "." });
});
});