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"; 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. }, }, {