diff --git a/src/adapters/cursor/tool-result-normalize.ts b/src/adapters/cursor/tool-result-normalize.ts index c89e247e9a..ab5b567b1d 100644 --- a/src/adapters/cursor/tool-result-normalize.ts +++ b/src/adapters/cursor/tool-result-normalize.ts @@ -35,23 +35,26 @@ function isNodeReplOrComputerUseTool(toolName?: string, toolNamespace?: string): * reads the blank [tool_result], concludes prior results were lost, and spirals into * re-orientation retries (devlog 260826_cursor_responses_gap, live subagent transcripts). */ +const CODEX_EXEC_BRIDGE_TOOL_NAMES = new Set([ + "exec", + "exec_command", + "shell_command", + "shell", + "local_shell", + "container.exec", +]); + 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" - // Codex CLI/desktop native tool names: the multi-round "이전 출력이 비어 있어 처음부터" - // restart loop reproduced via codex exec because `shell` was not in this set - // (devlog 260826 gap-8 QA round 2). - || lower === "shell" - || lower === "local_shell" - || lower === "container.exec" - || lower.startsWith("mcp_opencodex-responses_") - || lower.startsWith("mcp__opencodex-responses__") - ); + if (toolNamespace) { + return toolNamespace === "opencodex-responses" && CODEX_EXEC_BRIDGE_TOOL_NAMES.has(lower); + } + if (CODEX_EXEC_BRIDGE_TOOL_NAMES.has(lower)) return true; + return [...CODEX_EXEC_BRIDGE_TOOL_NAMES].some(name => ( + lower === `mcp_opencodex-responses_${name}` + || lower === `mcp__opencodex-responses__${name}` + )); } /** Failure states the Computer Use / node_repl runtime reports as PLAIN TEXT inside a non-error result. */ @@ -108,7 +111,7 @@ export function normalizeCursorToolResultText( 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, + isError, changed: true, }; } diff --git a/tests/cursor-exec-empty-result.test.ts b/tests/cursor-exec-empty-result.test.ts index 096aa01f9c..81eb7dd433 100644 --- a/tests/cursor-exec-empty-result.test.ts +++ b/tests/cursor-exec-empty-result.test.ts @@ -16,6 +16,40 @@ describe("codex exec bridge empty-result normalization (devlog 260826 gap-7)", ( expect(out.text).toContain("empty output"); }); + test("reserved namespace requires an exact exec bridge name", () => { + const out = normalizeCursorToolResultText("", { + toolName: "read_file", + toolNamespace: "opencodex-responses", + }); + expect(out).toEqual({ text: "", isError: false, changed: false }); + }); + + test("exec names in unrelated namespaces stay byte-identical", () => { + const out = normalizeCursorToolResultText("", { + toolName: "exec", + toolNamespace: "thirdparty", + isError: true, + }); + expect(out).toEqual({ text: "", isError: true, changed: false }); + }); + + test("namespace and display aliases must match the reserved identity exactly", () => { + for (const options of [ + { toolName: "read_file", toolNamespace: "evil-opencodex-responses-suffix" }, + { toolName: "mcp_opencodex-responses_read_file" }, + { toolName: "mcp__opencodex-responses__read_file" }, + ]) { + expect(normalizeCursorToolResultText("", options).changed).toBe(false); + } + }); + + test("existing exec errors remain errors after empty-output guidance is added", () => { + const out = normalizeCursorToolResultText("", { toolName: "exec", isError: true }); + expect(out.changed).toBe(true); + expect(out.isError).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);