Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/renderer/commands/keybindingMatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
});
});
7 changes: 5 additions & 2 deletions src/renderer/commands/keybindingMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
15 changes: 13 additions & 2 deletions src/renderer/components/providers/cursor/runtimeInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<reader> | 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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
23 changes: 15 additions & 8 deletions src/supervisor/crossagentMcp/toolRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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"] });
Expand Down
15 changes: 4 additions & 11 deletions src/supervisor/crossagentMcp/toolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

@SDSLeon SDSLeon Aug 29, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The root-level oneOf removal fixes Cursor compatibility, but the new comment says the request parser still enforces the choice and it currently does not.

  • With both prompt and tasks, spawnAgent takes the tasks branch and silently discards prompt.
  • With both run_id and run_ids, dispatch waits only for run_ids and silently ignores run_id.

Because the published schema now permits both shapes, a model-generated call can spawn or wait on the wrong work. Please keep the schema union-free, add explicit exactly-one validation before branch selection for both pairs, and add regression tests that pass both conflicting fields, assert an isError result, and verify that no spawn/wait manager method was called.

// `oneOf` at the root and fails the whole turn with a provider error. Callers
// pass either `prompt` or `tasks`; the request parser enforces that.
},
},
{
Expand Down Expand Up @@ -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.
},
},
{
Expand Down