From fa3f1ccb1eadbff53201c824e5cc84621943200c Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Tue, 6 Oct 2026 09:43:45 +0100 Subject: [PATCH] fix(server): Cursor long-context tiers turn on Max Mode Cursor's SDK never sets RequestedModel.maxMode, so Grok 4.7 at 500K is rejected with "Invalid parameters for registry model" and 1M tiers silently run at ~300K. Hook the SDK's own protobuf runtime to set Max Mode on requests whose context tier is above 300K, as Cursor's CLI does. Ported from upstream PR pingdotgg/t3code#15884 (fixes #15788). Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/provider/cursorSdk.test.ts | 166 ++++++++++++++++++++- apps/server/src/provider/cursorSdk.ts | 36 +++++ docs/fork-differences.md | 11 ++ 3 files changed, 205 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/cursorSdk.test.ts b/apps/server/src/provider/cursorSdk.test.ts index 6a34ee9fc40e..77340b33759f 100644 --- a/apps/server/src/provider/cursorSdk.test.ts +++ b/apps/server/src/provider/cursorSdk.test.ts @@ -4,17 +4,27 @@ import { describe, expect, it } from "vite-plus/test"; const cursorSdkUrl = new URL("./cursorSdk.ts", import.meta.url).href; -// Loads cursorSdk.ts in a fresh process. @cursor/sdk is stubbed so the guard -// can be tested without the real package. The vitest worker already has its -// own unhandledRejection listener, which would hide the process-exit behavior. +// Loads cursorSdk.ts in a fresh process. The send probe uses the real SDK; +// shell guard probes stub it. The worker's own unhandledRejection listener +// would hide the process-exit behavior. const probeProgram = ` import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { registerHooks } from "node:module"; +import { createRequire, registerHooks } from "node:module"; import { pathToFileURL } from "node:url"; import { spawn } from "node:child_process"; +const sdkRequire = createRequire(createRequire(${JSON.stringify(cursorSdkUrl)}).resolve("@cursor/sdk")); +const protobufUrl = pathToFileURL(sdkRequire.resolve("@bufbuild/protobuf")).href; +const { proto3, ScalarType } = sdkRequire("@bufbuild/protobuf"); +const mode = process.argv[1]; +if (mode === "sdk-model-requests") { + process.on("uncaughtException", error => { + console.error(error.stack); + process.exit(1); + }); +} const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); const stub = join(dir, "stub.cjs"); writeFileSync( @@ -23,17 +33,110 @@ writeFileSync( ); registerHooks({ resolve(specifier, context, next) { - if (specifier === "@cursor/sdk") { + if (specifier === "@cursor/sdk" && mode !== "sdk-model-requests") { return { url: pathToFileURL(stub).href, shortCircuit: true }; } + if (specifier === "@bufbuild/protobuf") { + return { url: protobufUrl, shortCircuit: true }; + } return next(specifier, context); }, }); -const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)}); -const mode = process.argv[1]; +const { isCursorShellSpawnFailure, createAgentPlatform } = await import(${JSON.stringify(cursorSdkUrl)}); const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); +if (mode === "sdk-model-requests") { + const { JsonlLocalAgentStore } = sdkRequire("@cursor/sdk"); + const store = new JsonlLocalAgentStore(join(dir, "store")); + const platform = await createAgentPlatform({ localStore: store, workspaceRef: dir }); + // Catalog validation is an external dependency; the SDK's send path and + // generated RequestedModel constructor stay real. Stop before transport. + platform.resolveLocalModelSelection = async selection => selection; + globalThis.fetch = async () => { throw new Error("Unexpected network request"); }; + const requests = []; + const initPartial = proto3.util.initPartial; + const captured = new Error("model request captured before transport"); + proto3.util.initPartial = function(source, target) { + initPartial.call(this, source, target); + if (target.getType().typeName !== "agent.v1.RequestedModel" || !target.modelId) return; + const decoded = target.getType().fromBinary(target.toBinary()); + requests.push({ model: decoded.modelId, maxMode: decoded.maxMode, + context: decoded.parameters.find(parameter => parameter.id === "context")?.value }); + throw captured; + }; + for (const [id, context] of [ + ["grok-4.7", "500k"], ["grok-4.7", "256k"], + ["claude-opus-5-5", "1m"], ["claude-opus-5-5", "300k"], + ]) { + const agent = await platform.createAgent({ + apiKey: "t3-offline-test-key", model: { id, params: [{ id: "context", value: context }] }, + mode: "plan", tools: [], local: { cwd: dir, store, autoReview: false, + settingSources: [], sandboxOptions: { enabled: false }, enableAgentRetries: false }, + }); + try { + await agent.send("Reply with exactly: pong"); + throw new Error("SDK did not construct a model request"); + } catch (error) { + if (error !== captured && error.cause !== captured) throw error; + } finally { + await agent[Symbol.asyncDispose](); + } + } + console.log(JSON.stringify(requests)); + process.exit(0); +} + +if (mode === "model-requests") { + const Parameter = proto3.makeMessageType("agent.v1.ModelParameter", [ + { no: 1, name: "id", kind: "scalar", T: ScalarType.STRING }, + { no: 2, name: "value", kind: "scalar", T: ScalarType.STRING }, + ]); + const fields = [ + { no: 1, name: "model_id", kind: "scalar", T: ScalarType.STRING }, + { no: 2, name: "max_mode", kind: "scalar", T: ScalarType.BOOL }, + { no: 3, name: "parameters", kind: "message", T: Parameter, repeated: true }, + ]; + const RequestedModel = proto3.makeMessageType("agent.v1.RequestedModel", fields); + const OtherMessage = proto3.makeMessageType("other.RequestedModel", fields); + const cases = [ + { modelId: "claude-opus-5-5", context: "1m" }, + { modelId: "grok-4.7", context: "500k" }, + { modelId: "gpt-5.6-sol", context: "1M" }, + { modelId: "claude-opus-5-5", context: "0.5m" }, + { modelId: "claude-opus-5-5", context: "300k" }, + { modelId: "grok-4.7", context: "256k" }, + { modelId: "gpt-5.6-sol", context: "272k" }, + { modelId: "claude-sonnet-4-6", context: "200k" }, + { modelId: "claude-opus-5-5", context: "0.3m" }, + { modelId: "default" }, + { modelId: "custom", context: "unknown" }, + { modelId: "claude-opus-5-5", context: "300k", maxMode: true }, + ]; + const requests = cases.map(({ context, ...selection }) => { + const source = { + ...selection, + parameters: [ + { id: "reasoning_effort", value: "high" }, + ...(context === undefined ? [] : [{ id: "context", value: context }]), + { id: "fast", value: "true" }, + ], + }; + const sourceBefore = JSON.stringify(source); + const request = new RequestedModel(source); + const decoded = RequestedModel.fromBinary(request.toBinary()); + return { + maxMode: decoded.maxMode, + modelId: decoded.modelId, + parameters: decoded.parameters.map(({ id, value }) => ({ id, value })), + sourceUnchanged: sourceBefore === JSON.stringify(source), + }; + }); + const unrelated = new OtherMessage({ parameters: [{ id: "context", value: "1m" }] }); + console.log(JSON.stringify({ requests, unrelatedMaxMode: unrelated.maxMode })); + process.exit(0); +} + if (mode === "predicate") { const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), { code: "ENOENT", @@ -139,7 +242,7 @@ function runProbe(mode: string): Promise<{ code: number | null; stdout: string; reject(new Error(`probe ${mode} timed out`)); }, 5_000); child.on("error", reject); - child.on("exit", (code) => { + child.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); }); @@ -163,6 +266,53 @@ describe("isCursorShellSpawnFailure", () => { }); }); +describe("Cursor long-context requests", () => { + it("sets Max Mode in real SDK sends for the reported context tiers", async () => { + const result = await runProbe("sdk-model-requests"); + expect(result.code, result.stderr.slice(-2_000)).toBe(0); + expect(JSON.parse(result.stdout)).toEqual([ + { model: "grok-4.7", context: "500k", maxMode: true }, + { model: "grok-4.7", context: "256k", maxMode: false }, + { model: "claude-opus-5-5", context: "1m", maxMode: true }, + { model: "claude-opus-5-5", context: "300k", maxMode: false }, + ]); + }); + + it("serializes Max Mode for long tiers and preserves standard requests", async () => { + const result = await runProbe("model-requests"); + expect(result.code).toBe(0); + const { requests, unrelatedMaxMode } = JSON.parse(result.stdout); + expect(requests.map((request: { maxMode: boolean }) => request.maxMode)).toEqual([ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + true, + ]); + expect(requests[0]).toEqual({ + maxMode: true, + modelId: "claude-opus-5-5", + parameters: [ + { id: "reasoning_effort", value: "high" }, + { id: "context", value: "1m" }, + { id: "fast", value: "true" }, + ], + sourceUnchanged: true, + }); + expect(requests.every((request: { sourceUnchanged: boolean }) => request.sourceUnchanged)).toBe( + true, + ); + expect(unrelatedMaxMode).toBe(false); + }); +}); + describe("Cursor shell spawn guard", () => { it("keeps the process alive when Cursor's shell spawn rejects", async () => { const result = await runProbe("cursor-shell"); diff --git a/apps/server/src/provider/cursorSdk.ts b/apps/server/src/provider/cursorSdk.ts index 07381bcf3a6f..92691ae16a5a 100644 --- a/apps/server/src/provider/cursorSdk.ts +++ b/apps/server/src/provider/cursorSdk.ts @@ -67,6 +67,42 @@ installCursorShellSpawnGuard(); // Cursor's Webpack chunks and local helpers must stay beside the SDK entry. // createRequire also loads that disk-backed package from a Node SEA executable. const requireCursorSdk = NodeModule.createRequire(import.meta.url); + +// The SDK omits RequestedModel.maxMode and exposes no way to set it. Cursor's +// current context tiers above 300K require Max Mode, as in its CLI. Patch only +// that message in the SDK's own disk-backed protobuf runtime, before loading +// the SDK. Remove this workaround when ModelSelection supports Max Mode. +// https://github.com/pingdotgg/t3code/issues/15788 +const requireCursorSdkDependency = NodeModule.createRequire( + requireCursorSdk.resolve("@cursor/sdk"), +); +const { proto3 } = requireCursorSdkDependency("@bufbuild/protobuf") as { + proto3: { + util: { + initPartial: ( + source: unknown, + target: { + getType: () => { typeName: string }; + maxMode?: boolean; + parameters?: ReadonlyArray<{ id: string; value: string }>; + }, + ) => void; + }; + }; +}; +const initPartial = proto3.util.initPartial; +proto3.util.initPartial = function (source, target) { + initPartial.call(this, source, target); + if (target.getType().typeName !== "agent.v1.RequestedModel" || target.maxMode) { + return; + } + const context = target.parameters?.find((parameter) => parameter.id === "context")?.value ?? ""; + const tier = /^(\d+(?:\.\d+)?)([km])$/i.exec(context); + if (tier && Number(tier[1]) * (tier[2]?.toLowerCase() === "m" ? 1_000_000 : 1_000) > 300_000) { + target.maxMode = true; + } +}; + export const { Agent, AuthenticationError, diff --git a/docs/fork-differences.md b/docs/fork-differences.md index 5d4775050606..fe8198884b90 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -139,6 +139,17 @@ Code: `apps/web/src/components/chat/ProviderAccountPicker.tsx` and `apps/web/src/components/chat/providerAccountSelection.ts`. User guide: [providers-codex.md](./user/providers-codex.md#switch-accounts-in-an-existing-thread). +## Cursor long-context tiers use Max Mode + +Choosing a Cursor context tier above 300K (Grok 4.7 at 500K, or 1M on other models) turns on +Cursor's Max Mode for the request, as Cursor's own CLI does. Upstream sends these tiers without +Max Mode, so Grok 4.7 at 500K fails with "Invalid parameters for registry model" and 1M tiers +silently run at about 300K. Max Mode is billed per token on usage-based Cursor plans. + +This is a port of upstream PR [#15884](https://github.com/pingdotgg/t3code/pull/15884) for +[#15788](https://github.com/pingdotgg/t3code/issues/15788). Remove this section when upstream merges +it. Code: `apps/server/src/provider/cursorSdk.ts`. + ## Agents panel drilldowns Upstream makes every subagent a child thread, lists a thread's subagents under **Lineage** in the