From 46991d4db039796ebba40108936684ca25c0f588 Mon Sep 17 00:00:00 2001 From: "Maicon (Playtix)" Date: Fri, 28 Aug 2026 23:12:41 -0300 Subject: [PATCH 1/2] fix: three upstream crashes and a dead SDK update path - chat: guard empty shell pipelines in parseHeadFileView. A blank or pipe-only command made splitShellPipeline return [], so parts[parts.length - 1] handed undefined to splitShellWords and the renderer died with "e is not iterable". - crossagents: drop the root-level oneOf from spawn_agent/wait_for_agent. Cursor's backend rejects union-typed tool schemas and fails the whole turn with "NonRetriableError: Provider Error", for every model. - cursor: allow updating a globally installed @cursor/sdk. The update command only accepted global-npm/global-pnpm, which the discovery only reports from the deferred npm root -g probe; a Node prefix of its own resolves as global-inferred, so the action was dead and the agent updater refreshed the CLI instead. Each fix ships a regression test. Co-Authored-By: Claude Opus 5 (1M context) --- .../providers/cursor/runtimeInstall.ts | 15 ++++++++++-- .../parts/items/commandSummary.test.ts | 11 +++++++++ .../ChatPane/parts/items/commandSummary.ts | 5 ++-- .../parts/cursorRuntimeInstall.test.ts | 20 ++++++++++++++++ .../crossagentMcp/toolRegistry.test.ts | 23 ++++++++++++------- src/supervisor/crossagentMcp/toolRegistry.ts | 15 ++++-------- 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/renderer/components/providers/cursor/runtimeInstall.ts b/src/renderer/components/providers/cursor/runtimeInstall.ts index 20d435d70..15bcaa46c 100644 --- a/src/renderer/components/providers/cursor/runtimeInstall.ts +++ b/src/renderer/components/providers/cursor/runtimeInstall.ts @@ -90,6 +90,17 @@ export function cursorSdkInstallCommand(project: Project): string { ); } +/** + * Global installs the SDK discovery can report. `global-npm` / `global-pnpm` + * only come from the deferred `npm root -g` / `pnpm root -g` probe, which never + * runs once a filesystem candidate already matched — a Node prefix of its own + * (~/.local, nvm, fnm, volta, Homebrew) resolves as `global-inferred` instead. + * Treating only the probe sources as updatable left the update action dead for + * those installs, so the button fell through to the agent updater and refreshed + * the CLI while the SDK stayed on its old version. + */ +const NPM_UPDATABLE_SDK_SOURCES = new Set(["global-npm", "global-explicit", "global-inferred"]); + export function cursorSdkUpdateCommand(status: AgentStatus, project: Project): string | undefined { const source = cursorRuntimeInstallState(status).sdkInstallationSource; if (source === "global-pnpm") { @@ -100,13 +111,13 @@ export function cursorSdkUpdateCommand(status: AgentStatus, project: Project): s MISSING_PNPM_MESSAGE, ); } - if (source !== "global-npm") return undefined; + if (!source || !NPM_UPDATABLE_SDK_SOURCES.has(source)) return undefined; return cursorSdkInstallCommand(project); } export function canUpdateCursorSdk(status: AgentStatus): boolean { const source = cursorRuntimeInstallState(status).sdkInstallationSource; - return source === "global-npm" || source === "global-pnpm"; + return source === "global-pnpm" || (!!source && NPM_UPDATABLE_SDK_SOURCES.has(source)); } export function cursorRuntimeInstallCommand( diff --git a/src/renderer/components/thread/ChatPane/parts/items/commandSummary.test.ts b/src/renderer/components/thread/ChatPane/parts/items/commandSummary.test.ts index 50685c769..e56bb08fd 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/commandSummary.test.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/commandSummary.test.ts @@ -230,6 +230,17 @@ describe("humanIntentTitle", () => { expect(commandIntentDisplay("cat a | nl | grep x | head -20").kind).not.toBe("view"); }); + it("labels blank and pipe-only commands without crashing", () => { + // Regression: an empty pipeline reached parseHeadFileView, which indexed + // parts[-1] and iterated undefined ("e is not iterable" renderer crash). + expect(commandIntentDisplay("").kind).toBe("command"); + expect(commandIntentDisplay(" ").kind).toBe("command"); + expect(commandIntentDisplay("|").kind).toBe("command"); + expect(commandIntentDisplay(" | | ").kind).toBe("command"); + expect(commandIntentDisplay(`bash -c ''`).kind).toBe("command"); + expect(commandIntentDisplay("cd /tmp && ").kind).toBe("command"); + }); + it("does not treat head byte windows as line views", () => { expect(commandIntentDisplay("cat src/foo.ts | head -c 200").kind).toBe("command"); expect(commandIntentDisplay("head -c-200 src/foo.ts").kind).toBe("command"); diff --git a/src/renderer/components/thread/ChatPane/parts/items/commandSummary.ts b/src/renderer/components/thread/ChatPane/parts/items/commandSummary.ts index 99aa3ab5a..27b42607e 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/commandSummary.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/commandSummary.ts @@ -446,8 +446,9 @@ function parseHeadFileView(command: string): PipedFileView | null { const parts = splitShellPipeline(command); // A filter between the reader and head (`cat f | grep x | head`) changes what // head sees, so only the direct ` | head` and bare `head file` forms - // map cleanly to a 1..N window of a single file. - if (parts.length > 2) return null; + // map cleanly to a 1..N window of a single file. An empty pipeline (blank or + // pipe-only command) has no segment to inspect. + if (parts.length === 0 || parts.length > 2) return null; const invocation = parseHeadInvocation(splitShellWords(parts[parts.length - 1]!)); if (!invocation) return null; diff --git a/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.test.ts b/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.test.ts index 851391d87..9d3b3ff3d 100644 --- a/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.test.ts +++ b/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.test.ts @@ -136,4 +136,24 @@ describe("Cursor runtime installation", () => { expect(canUpdateCursorSdk(projectStatus)).toBe(false); expect(cursorSdkUpdateCommand(projectStatus, posixProject)).toBeUndefined(); }); + + it("updates global installs the discovery reports as inferred or explicit", () => { + // A Node prefix of its own (~/.local, nvm, fnm, volta, Homebrew) matches a + // filesystem candidate before the `npm root -g` probe runs, so the source is + // never "global-npm". Requiring the probe sources left the update action + // dead and the agent updater refreshed the CLI instead of the SDK. + for (const installationSource of ["global-inferred", "global-explicit"]) { + const globalStatus = status(true, true, { installationSource }); + expect(canUpdateCursorSdk(globalStatus)).toBe(true); + expect(cursorSdkUpdateCommand(globalStatus, posixProject)).toContain( + "npm install -g '@cursor/sdk@^1.0.24'", + ); + } + + for (const installationSource of ["configured", "node-path"]) { + const scopedStatus = status(true, true, { installationSource }); + expect(canUpdateCursorSdk(scopedStatus)).toBe(false); + expect(cursorSdkUpdateCommand(scopedStatus, posixProject)).toBeUndefined(); + } + }); }); diff --git a/src/supervisor/crossagentMcp/toolRegistry.test.ts b/src/supervisor/crossagentMcp/toolRegistry.test.ts index 344403ea8..a130f20b4 100644 --- a/src/supervisor/crossagentMcp/toolRegistry.test.ts +++ b/src/supervisor/crossagentMcp/toolRegistry.test.ts @@ -529,11 +529,17 @@ describe("subagent tool registration", () => { it("declares required fields on the subagent tool schemas", () => { const byName = new Map(TOOLS.map((tool) => [tool.name, tool])); + // Cursor's backend rejects tool schemas with a root-level union and fails the + // whole turn with a provider error, so the prompt/tasks choice is documented + // in the tool description and enforced by the request parser instead. + expect(byName.get("spawn_agent")!.inputSchema).not.toHaveProperty("oneOf"); + expect(byName.get("wait_for_agent")!.inputSchema).not.toHaveProperty("oneOf"); expect(byName.get("spawn_agent")!.inputSchema).toMatchObject({ - oneOf: [ - { type: "object", required: ["prompt"] }, - { type: "object", required: ["tasks"] }, - ], + type: "object", + properties: expect.objectContaining({ + prompt: expect.anything(), + tasks: expect.anything(), + }), }); expect(byName.get("get_agent")!.inputSchema).toMatchObject({ required: ["id"] }); expect(byName.get("set_routing_preference")!.inputSchema).toMatchObject({ @@ -543,10 +549,11 @@ describe("subagent tool registration", () => { required: ["tags"], }); expect(byName.get("wait_for_agent")!.inputSchema).toMatchObject({ - oneOf: [ - { type: "object", required: ["run_id"] }, - { type: "object", required: ["run_ids"] }, - ], + type: "object", + properties: expect.objectContaining({ + run_id: expect.anything(), + run_ids: expect.anything(), + }), }); expect(byName.get("get_status")!.inputSchema).toMatchObject({ required: ["run_id"] }); expect(byName.get("cancel")!.inputSchema).toMatchObject({ required: ["run_id"] }); diff --git a/src/supervisor/crossagentMcp/toolRegistry.ts b/src/supervisor/crossagentMcp/toolRegistry.ts index 0c311e0ba..ba9258dea 100644 --- a/src/supervisor/crossagentMcp/toolRegistry.ts +++ b/src/supervisor/crossagentMcp/toolRegistry.ts @@ -226,12 +226,9 @@ const RAW_TOOLS: ToolSpec[] = [ description: TIMEOUT_S_DESCRIPTION, }, }, - // Keep every root union branch explicitly object-typed. Some OpenAI-compatible - // providers reject `required`-only branches because they also match non-objects. - oneOf: [ - { type: "object", required: ["prompt"] }, - { type: "object", required: ["tasks"] }, - ], + // No root-level union here: Cursor's backend rejects tool schemas that carry + // `oneOf` at the root and fails the whole turn with a provider error. Callers + // pass either `prompt` or `tasks`; the request parser enforces that. }, }, { @@ -288,11 +285,7 @@ const RAW_TOOLS: ToolSpec[] = [ after_output_chars: AFTER_OUTPUT_CHARS_PROPERTY, after_output_chars_by_run: AFTER_OUTPUT_CHARS_BY_RUN_PROPERTY, }, - // See the spawn_agent schema above for why these branches repeat the root type. - oneOf: [ - { type: "object", required: ["run_id"] }, - { type: "object", required: ["run_ids"] }, - ], + // No root-level union — see the spawn_agent schema above. }, }, { From 26460e0941c913d638796d879ccd72254d7e7b20 Mon Sep 17 00:00:00 2001 From: "Maicon (Playtix)" Date: Sat, 29 Aug 2026 12:35:08 -0300 Subject: [PATCH 2/2] fix(keybindings): ignore keyboard events without a key The global keydown listener forwards every event to eventToKeybinding, and synthetic or stand-in events can arrive without `key`. normalizeKeyPart called `part.toLowerCase()` unguarded, so one such event threw inside a listener that has no error boundary and replaced the whole app shell with the crash screen ("Cannot read properties of undefined (reading 'toLowerCase')"). Normalizing an absent key now yields undefined, which simply matches no keybinding. Adds a regression test. Co-Authored-By: Claude Opus 5 (1M context) --- src/renderer/commands/keybindingMatcher.test.ts | 16 ++++++++++++++++ src/renderer/commands/keybindingMatcher.ts | 7 +++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/renderer/commands/keybindingMatcher.test.ts b/src/renderer/commands/keybindingMatcher.test.ts index b235232f7..d40943932 100644 --- a/src/renderer/commands/keybindingMatcher.test.ts +++ b/src/renderer/commands/keybindingMatcher.test.ts @@ -56,3 +56,19 @@ describe("keybindingMatcher", () => { expect(canonicalizeKeybinding("Ctrl+Shift+[", "win32")).toBe("ctrl+shift+["); }); }); + +describe("eventToKeybinding with incomplete events", () => { + it("ignores keyboard events that carry no key", () => { + // Regression: synthetic events without `key` reached normalizeKeyPart and + // crashed the renderer instead of simply matching no keybinding. + const event = { + key: undefined as unknown as string, + ctrlKey: false, + metaKey: true, + altKey: false, + shiftKey: false, + }; + expect(() => eventToKeybinding(event, "darwin")).not.toThrow(); + expect(eventToKeybinding(event, "darwin")).toBe(""); + }); +}); diff --git a/src/renderer/commands/keybindingMatcher.ts b/src/renderer/commands/keybindingMatcher.ts index 2fe5a395e..6747e64fa 100644 --- a/src/renderer/commands/keybindingMatcher.ts +++ b/src/renderer/commands/keybindingMatcher.ts @@ -69,8 +69,11 @@ function canonicalizeParts(parts: string[], platform: PlatformName): string { return [...MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)), ...main].join("+"); } -function normalizeKeyPart(part: string, platform: PlatformName): string | undefined { - const lower = part.toLowerCase(); +function normalizeKeyPart(part: string | undefined, platform: PlatformName): string | undefined { + // The global keydown listener forwards every event here, and synthetic or + // stand-in events can arrive without `key`. Reading it unguarded threw and, + // because the listener has no boundary, took down the whole renderer. + const lower = part?.toLowerCase(); if (!lower) return undefined; if (lower === "cmd" || lower === "command" || lower === "super" || lower === "win") { return "meta";