diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index fd6556edf9..20bfba0d83 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -656,6 +656,9 @@ export function buildCursorToolGuidanceSystemNote( codeMode ? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers." : undefined, + codeMode + ? "NEVER attempt Cursor-native Shell, Read, Grep, List, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces." + : undefined, hasBareExec ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.` : undefined, diff --git a/src/adapters/cursor/tool-result-normalize.ts b/src/adapters/cursor/tool-result-normalize.ts index b87ef29854..b417e7704c 100644 --- a/src/adapters/cursor/tool-result-normalize.ts +++ b/src/adapters/cursor/tool-result-normalize.ts @@ -29,6 +29,25 @@ function isNodeReplOrComputerUseTool(toolName?: string, toolNamespace?: string): return lower.startsWith("mcp__node_repl") || lower.startsWith("mcp__computer_use"); } +/** + * Codex exec / shell-bridge tool names (flat and MCP-prefixed display aliases). An empty result + * here is almost always a code-mode cell that never called text()/notify() — the cursor model + * reads the blank [tool_result], concludes prior results were lost, and spirals into + * re-orientation retries (devlog 260826_cursor_responses_gap, live subagent transcripts). + */ +function isCodexExecBridgeTool(toolName?: string, toolNamespace?: string): boolean { + if (toolNamespace && toolNamespace.includes("opencodex-responses")) return true; + if (!toolName) return false; + const lower = toolName.toLowerCase(); + return ( + lower === "exec" + || lower === "exec_command" + || lower === "shell_command" + || lower.startsWith("mcp_opencodex-responses_") + || lower.startsWith("mcp__opencodex-responses__") + ); +} + /** Failure states the Computer Use / node_repl runtime reports as PLAIN TEXT inside a non-error result. */ const RUNTIME_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; guidance: string }> = [ { @@ -80,6 +99,13 @@ export function normalizeCursorToolResultText( changed: true, }; } + if (isCodexExecBridgeTool(options.toolName, options.toolNamespace) && EMPTY_EXEC_OUTPUT_REGEX.test(text.trim())) { + return { + text: "[empty output: the exec cell completed but emitted nothing. This is NOT lost context and NOT a blocked tool — in code mode call text(...) or notify(...) on any value you need to see (a bare await tools.exec_command(...) is not echoed automatically); in shell mode the command simply printed nothing. Do not re-run the same call expecting different output.]", + isError: false, + changed: true, + }; + } if (!isError) { for (const { marker, guidance } of RUNTIME_FAILURE_GUIDANCE) { if (text.includes(marker)) { @@ -89,4 +115,3 @@ export function normalizeCursorToolResultText( } return { text, isError, changed: false }; } - diff --git a/tests/cursor-exec-empty-result.test.ts b/tests/cursor-exec-empty-result.test.ts new file mode 100644 index 0000000000..92a3fd6d57 --- /dev/null +++ b/tests/cursor-exec-empty-result.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeCursorToolResultText } from "../src/adapters/cursor/tool-result-normalize"; + +describe("codex exec bridge empty-result normalization (devlog 260826 gap-7)", () => { + test("empty exec cell output becomes explanatory text, not an error", () => { + const out = normalizeCursorToolResultText("Script completed\nWall time 0.1 seconds\nOutput:\n", { toolName: "exec" }); + expect(out.changed).toBe(true); + expect(out.isError).toBe(false); + expect(out.text).toContain("NOT lost context"); + expect(out.text).toContain("text(...)"); + }); + + test("mcp display alias names route the same way", () => { + const out = normalizeCursorToolResultText("", { toolName: "mcp_opencodex-responses_exec" }); + expect(out.changed).toBe(true); + expect(out.text).toContain("empty output"); + }); + + test("shell_command empty output routes too", () => { + const out = normalizeCursorToolResultText("", { toolName: "shell_command" }); + expect(out.changed).toBe(true); + }); + + test("non-empty exec output passes through byte-identical", () => { + const out = normalizeCursorToolResultText("Output:\nhello", { toolName: "exec" }); + expect(out.changed).toBe(false); + expect(out.text).toBe("Output:\nhello"); + }); + + test("computer-use empties keep the original error semantics", () => { + const out = normalizeCursorToolResultText("", { toolName: "screenshot" }); + expect(out.isError).toBe(true); + expect(out.text).toContain("get_app_state"); + }); + + test("unrelated tools with empty output stay untouched", () => { + const out = normalizeCursorToolResultText("", { toolName: "get_weather" }); + expect(out.changed).toBe(false); + }); +});