Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/adapters/cursor/tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 26 additions & 1 deletion src/adapters/cursor/tool-result-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match only exec aliases under the Responses namespace

When any unrelated Responses-owned tool returns empty output, this namespace check classifies it as an exec bridge before inspecting its name. For example, wait with namespace opencodex-responses is recognized by isCursorWaitTool, while display aliases such as mcp_opencodex-responses_apply_patch share the same generic prefix; both now receive misleading exec-cell guidance rather than retaining their actual result semantics. Require the normalized tool name to be exactly exec, exec_command, or shell_command instead of accepting the provider namespace or every prefixed tool.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

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 }> = [
{
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve existing errors when annotating empty exec results

When an exec or shell-bridge result arrives with isError: true and an empty payload—or even the explicitly matched Script failed wrapper—this branch overwrites the flag with false. The Cursor wire result therefore reports a failed command as successful and tells the model not to retry, potentially allowing work to continue from a command that never completed; preserve the incoming isError value or apply the non-error annotation only when it is already false.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

changed: true,
};
}
if (!isError) {
for (const { marker, guidance } of RUNTIME_FAILURE_GUIDANCE) {
if (text.includes(marker)) {
Expand All @@ -89,4 +115,3 @@ export function normalizeCursorToolResultText(
}
return { text, isError, changed: false };
}

40 changes: 40 additions & 0 deletions tests/cursor-exec-empty-result.test.ts
Original file line number Diff line number Diff line change
@@ -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("<empty>", { 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);
});
});
Loading