From 77e4fbacc210a4a7bcc12afcd2e332f8e413b2a5 Mon Sep 17 00:00:00 2001 From: Jonathan Li <47408717+jonathanli12@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:51:18 -0700 Subject: [PATCH] fix(cursor): pin unified exec so wait cannot ship alone Codex Desktop advertises exec/wait, not only exec_command/shell_command. Cursor transport truncation dropped exec and left wait, so Grok turns loop on exec cell not found. Treat exec (bare and opencodex-responses) as the execution path, force-admit it, and omit wait when no cell creator remains. --- src/adapters/cursor/request-builder.ts | 64 +++++++++++++++--- src/adapters/cursor/tool-definitions.ts | 24 +++++++ tests/cursor-request-builder.test.ts | 90 +++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 10 deletions(-) diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 872a9040c..6c633909c 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -21,6 +21,8 @@ import { cursorToolsForActivePrompt, isCursorStructuredEditToolName, isBareCodexShellBridgeTool, + isCursorExecutionPathTool, + isCursorWaitTool, } from "./tool-definitions"; import { lookupCursorThreadConversation } from "./thread-continuity"; @@ -39,21 +41,25 @@ function explicitlySelectedNames(choice: OcxToolChoice | undefined): Set } function toolPriority(tool: OcxTool, selectedNames: ReadonlySet): number { - // Shell bridge and apply_patch outrank unrelated allowed_tools entries so a large - // selected filler cannot starve the Codex execution path during truncation (#399). + // Execution path (bare or opencodex-responses `exec` / `exec_command` / `shell_command`) + // outranks filler so a crowded catalog cannot drop the Codex shell bridge (#399). + if (isCursorExecutionPathTool(tool)) return 0; if (isBareCodexShellBridgeTool(tool)) return 0; - if (!tool.namespace && tool.name === "apply_patch") return 1; + // `wait` only resumes a yielded exec cell. Keep it with the execution path, but after + // `exec` itself so a large wait schema cannot starve the tool that creates the cell. + if (isCursorWaitTool(tool)) return 1; + if (!tool.namespace && tool.name === "apply_patch") return 2; // Structured edit tools convert to apply_patch on the return path, so they must survive the // same byte/count truncation as the freeform tool they stand in for (#1017). - if (!tool.namespace && isCursorStructuredEditToolName(tool.name)) return 1; - if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return 2; - if (tool.loadedFromToolSearch) return 3; - if (!tool.namespace) return 4; - return 5; + if (!tool.namespace && isCursorStructuredEditToolName(tool.name)) return 2; + if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return 3; + if (tool.loadedFromToolSearch) return 4; + if (!tool.namespace) return 5; + return 6; } function isPinnedCursorTool(tool: OcxTool, selectedNames: ReadonlySet): boolean { - return toolPriority(tool, selectedNames) <= 2; + return toolPriority(tool, selectedNames) <= 3; } /** @@ -96,7 +102,7 @@ export function applyCursorToolBudget( return true; }; - // Phase 1: selected tools + shell bridge + apply_patch (priority <= 2). + // Phase 1: selected tools + execution path + apply_patch (priority <= 3). // Pins are admitted before filler so a crowded catalog cannot drop the Codex execution path (#399). for (const candidate of candidates) { if (!isPinnedCursorTool(candidate.tool, selectedNames)) continue; @@ -108,6 +114,44 @@ export function applyCursorToolBudget( tryKeep(candidate.tool); } + const evictNonExecutionPath = (needBytes: number): void => { + for (let i = kept.length - 1; i >= 0; i--) { + const occupant = kept[i]; + if (!occupant || isCursorExecutionPathTool(occupant)) continue; + kept.splice(i, 1); + keptSet.delete(occupant); + keptBytes -= cursorMcpToolEncodedSize(occupant, toolChoice); + if (kept.length < CURSOR_TOOL_COUNT_LIMIT && keptBytes + needBytes <= CURSOR_TOOL_BYTES_LIMIT) { + return; + } + } + }; + + // Force-admit at least one execution-path tool when one was eligible. Priority-0 + // admission can still fail if the tool itself is larger than leftover room after + // earlier same-priority pins; evict wait/patch/filler rather than ship wait-only. + for (const tool of eligible) { + if (!isCursorExecutionPathTool(tool) || keptSet.has(tool)) continue; + const need = cursorMcpToolEncodedSize(tool, toolChoice); + if (need > CURSOR_TOOL_BYTES_LIMIT) continue; + evictNonExecutionPath(need); + tryKeep(tool); + if (keptSet.has(tool)) break; + } + + const eligibleHasExecutionPath = eligible.some(isCursorExecutionPathTool); + const keptHasExecutionPath = eligible.some(tool => keptSet.has(tool) && isCursorExecutionPathTool(tool)); + // Never advertise `wait` after dropping the tool that creates the exec cell. + if (eligibleHasExecutionPath && !keptHasExecutionPath) { + for (const tool of eligible) { + if (!isCursorWaitTool(tool) || !keptSet.has(tool)) continue; + keptSet.delete(tool); + const index = kept.indexOf(tool); + if (index >= 0) kept.splice(index, 1); + keptBytes -= cursorMcpToolEncodedSize(tool, toolChoice); + } + } + return { tools: eligible.filter(tool => keptSet.has(tool)), // Synthetic tools are pinned in phase 1 and never reported as omitted; the note counts only diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 9702aa28f..7bdc544eb 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -7,6 +7,9 @@ import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from export const OCX_RESPONSES_TOOL_PROVIDER = "opencodex-responses"; export const CODEX_EXEC_COMMAND_TOOL = "exec_command"; export const CODEX_SHELL_COMMAND_TOOL = "shell_command"; +/** Codex Desktop unified-exec client tool. Companion of `wait`; not an `exec_command` schema alias. */ +export const CODEX_UNIFIED_EXEC_TOOL = "exec"; +export const CODEX_WAIT_TOOL = "wait"; export const CODEX_APPLY_PATCH_TOOL = "apply_patch"; export const CURSOR_EDIT_FILE_TOOL = "edit_file"; export const CURSOR_MULTI_EDIT_TOOL = "multi_edit"; @@ -167,6 +170,27 @@ export function isBareCodexShellBridgeTool(tool: Pick): boolean { + return isCursorResponsesProvider(tool.namespace) + && (CURSOR_EXECUTION_PATH_TOOL_NAMES as readonly string[]).includes(tool.name); +} + +/** `wait` only resumes a yielded exec cell; it is unusable without an execution-path tool. */ +export function isCursorWaitTool(tool: Pick): boolean { + return isCursorResponsesProvider(tool.namespace) && tool.name === CODEX_WAIT_TOOL; +} + /** @deprecated Prefer isBareCodexShellBridgeTool; kept for older call sites/tests. */ function isBareCodexExecCommandTool(tool: Pick): boolean { return isBareCodexShellBridgeTool(tool); diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index 6db299ce1..efec3468b 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -440,6 +440,96 @@ describe("Cursor request builder", () => { expect(budget.tools.length).toBeLessThanOrEqual(CURSOR_TOOL_COUNT_LIMIT); }); + test("pins Codex Desktop unified exec through count truncation", () => { + const regular = Array.from({ length: CURSOR_TOOL_COUNT_LIMIT + 20 }, (_, index) => ({ + name: `regular_${index}`, + namespace: "mcp__regular", + description: "Regular", + parameters: {}, + })); + const exec = { name: "exec", description: "Run", parameters: { type: "object", properties: { cmd: { type: "string" } } } }; + const budget = applyCursorToolBudget([...regular, exec], "auto"); + + expect(budget.tools).toContain(exec); + expect(budget.omitted).not.toContain(exec); + expect(budget.tools.length).toBeLessThanOrEqual(CURSOR_TOOL_COUNT_LIMIT); + }); + + test("pins namespaced opencodex-responses exec ahead of filler", () => { + const filler = Array.from({ length: 80 }, (_, index) => ({ + name: `filler_${index}`, + namespace: "mcp__filler", + description: "y".repeat(3_000), + parameters: { type: "object", properties: {} }, + })); + const exec = { + name: "exec", + namespace: "opencodex-responses", + description: "Run", + parameters: { type: "object", properties: { cmd: { type: "string" } } }, + }; + const wait = { + name: "wait", + namespace: "opencodex-responses", + description: "Resume", + parameters: { type: "object", properties: { id: { type: "string" } } }, + }; + const catalog = [...filler, wait, exec]; + expect(cursorMcpToolsEncodedSize(catalog, "auto")).toBeGreaterThan(CURSOR_TOOL_BYTES_LIMIT); + const budget = applyCursorToolBudget(catalog, "auto"); + + expect(budget.tools).toContain(exec); + expect(budget.tools).toContain(wait); + expect(budget.omitted.some(tool => tool.namespace === "mcp__filler")).toBe(true); + }); + + test("keeps unified exec when a large apply_patch would otherwise consume the byte budget first", () => { + const hugePatch = { + name: "apply_patch", + description: "x".repeat(Math.floor(CURSOR_TOOL_BYTES_LIMIT * 0.7)), + parameters: { type: "object", properties: {} }, + freeform: true, + }; + const exec = { + name: "exec", + description: "Run", + parameters: { type: "object", properties: { cmd: { type: "string" } } }, + }; + const wait = { + name: "wait", + description: "Resume", + parameters: { type: "object", properties: { id: { type: "string" } } }, + }; + const filler = Array.from({ length: 40 }, (_, index) => ({ + name: `filler_${index}`, + namespace: "mcp__filler", + description: "y".repeat(2_000), + parameters: { type: "object", properties: {} }, + })); + const budget = applyCursorToolBudget([hugePatch, wait, ...filler, exec], "auto"); + + expect(budget.tools).toContain(exec); + expect(cursorMcpToolsEncodedSize(budget.tools, "auto")).toBeLessThanOrEqual(CURSOR_TOOL_BYTES_LIMIT); + }); + + test("omits wait when the execution path cannot fit in the Cursor byte budget", () => { + const exec = { + name: "exec", + description: "x".repeat(CURSOR_TOOL_BYTES_LIMIT + 10_000), + parameters: { type: "object", properties: {} }, + }; + const wait = { + name: "wait", + description: "Resume", + parameters: { type: "object", properties: { id: { type: "string" } } }, + }; + const budget = applyCursorToolBudget([wait, exec], "auto"); + + expect(budget.tools).not.toContain(exec); + expect(budget.tools).not.toContain(wait); + expect(budget.omitted).toEqual(expect.arrayContaining([exec, wait])); + }); + test("adds an honest recovery note only when tool_search survives", () => { const tools = [ { name: "tool_search", description: "Discover", parameters: {}, toolSearch: true },