From c441eac156b3cc78e10d43761c970bd75b604d2d Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Tue, 6 Oct 2026 10:25:44 +0100 Subject: [PATCH] fix(server): Cursor turns send the picker's default options Cursor treats an omitted option as the model's standard tier, but T3 only sends options the user changed. An untouched composer, delegated task, launched thread or scheduled task ran at ~300K while the picker showed 1M (500K for Grok 4.7). The Cursor driver now fills options the selection leaves out with the defaults the picker shows, on agent open and every send. Fast stays off unless chosen, matching the composer. delegate_task on the parent's provider and model now merges requested options over the parent's instead of replacing them, so an effort-only child keeps the parent's context window. Fixes upstream pingdotgg/t3code#16149 in the fork. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/OrchestratorMcpService.test.ts | 95 +++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 26 +++-- .../src/provider/CursorProvider.test.ts | 57 +++++++++++ .../src/provider/Drivers/CursorDriver.ts | 45 ++++++++- apps/server/src/provider/cursorSdkModel.ts | 24 ++++- docs/fork-differences.md | 18 ++++ packages/contracts/src/orchestratorMcp.ts | 8 +- 7 files changed, 259 insertions(+), 14 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 5fb1a3b85d2d..df68d71f63c8 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1318,4 +1318,99 @@ describe("OrchestratorMcpService provider resolution", () => { } }), ); + + it.effect("merges requested options over the parent's when the child keeps its model", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const parentModelSelection = { + instanceId: codexInstanceId, + model: "gpt-5.4", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ], + } as const; + const otherModel = "gpt-5.4-mini"; + const cases = [ + { + name: "same-model", + model: undefined, + expected: { + instanceId: codexInstanceId, + model: "gpt-5.4", + options: [ + { id: "contextWindow", value: "1m" }, + { id: "reasoningEffort", value: "low" }, + ], + }, + }, + { + name: "other-model", + model: otherModel, + expected: { + instanceId: codexInstanceId, + model: otherModel, + options: [{ id: "reasoningEffort", value: "low" }], + }, + }, + ] as const; + + for (const testCase of cases) { + const dispatched = yield* Ref.make>([]); + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + ...spawnPolicyReads, + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection([], parentModelSelection) + : childProjection, + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({ sequence: 1, storedEvents: [] } as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + ...providerSnapshot({ instanceId: codexInstanceId, driver, model: "gpt-5.4" }), + models: ["gpt-5.4", otherModel].map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + })), + }, + ]), + }), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + yield* service + .delegateTask(scope, { + task: "Summarize the diff.", + target: { + providerInstanceId: codexInstanceId, + ...(testCase.model === undefined ? {} : { model: testCase.model }), + options: [{ id: "reasoningEffort", value: "low" }], + }, + mode: "async", + clientRequestId: `delegate-options-${testCase.name}`, + }) + .pipe(Effect.ignore); + const commands = yield* Ref.get(dispatched); + const request = commands[0] as { modelSelection: unknown } | undefined; + assert.deepEqual(request?.modelSelection, testCase.expected, testCase.name); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + } + }), + ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index b8f056bdd1a0..e604f85df49c 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1160,15 +1160,29 @@ const make = Effect.gen(function* () { } } + // On the parent's model, requested options override the parent's by id, so an + // effort-only request keeps the parent's other choices such as its context window. + const inheritsModel = + instanceId === inheritedSelection.instanceId && model === inheritedSelection.model; return { modelSelection: - instanceId === inheritedSelection.instanceId && - model === inheritedSelection.model && requestedOptions === undefined - ? inheritedSelection - : requestedOptions === undefined - ? { instanceId, model } - : { instanceId, model, options: requestedOptions }, + ? inheritsModel + ? inheritedSelection + : { instanceId, model } + : { + instanceId, + model, + options: inheritsModel + ? [ + ...(inheritedSelection.options ?? []).filter( + (inherited) => + !requestedOptions.some((option) => option.id === inherited.id), + ), + ...requestedOptions, + ] + : requestedOptions, + }, }; }); diff --git a/apps/server/src/provider/CursorProvider.test.ts b/apps/server/src/provider/CursorProvider.test.ts index 7fe35db9ea32..cb5140b49048 100644 --- a/apps/server/src/provider/CursorProvider.test.ts +++ b/apps/server/src/provider/CursorProvider.test.ts @@ -14,6 +14,7 @@ import { checkCursorProviderStatus, } from "./CursorProvider.ts"; import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; +import { withCursorDefaultParameters } from "./cursorSdkModel.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); @@ -165,6 +166,62 @@ describe("Cursor SDK model discovery", () => { }); }); +describe("withCursorDefaultParameters", () => { + // Cursor defaults Fast on for some models; T3 keeps it off unless chosen. + const fastByDefaultModel = { + ...sdkParameterizedModel, + variants: [ + { + ...sdkParameterizedModel.variants[0]!, + params: sdkParameterizedModel.variants[0]!.params.map((parameter) => + parameter.id === "fast" ? { ...parameter, value: "true" } : parameter, + ), + }, + ], + } satisfies SDKModel; + const descriptors = buildCursorCapabilitiesFromSdkModel(fastByDefaultModel).optionDescriptors; + + it("sends the picker defaults for parameters the selection leaves out", () => { + expect(withCursorDefaultParameters({ id: "claude-opus-4-8" }, descriptors)).toEqual({ + id: "claude-opus-4-8", + params: [ + { id: "effort", value: "high" }, + { id: "context", value: "1m" }, + { id: "fast", value: "false" }, + { id: "thinking", value: "true" }, + ], + }); + }); + + it("keeps chosen parameters and fills only the rest", () => { + expect( + withCursorDefaultParameters( + { + id: "claude-opus-4-8", + params: [ + { id: "context", value: "300k" }, + { id: "fast", value: "true" }, + ], + }, + descriptors, + ), + ).toEqual({ + id: "claude-opus-4-8", + params: [ + { id: "context", value: "300k" }, + { id: "fast", value: "true" }, + { id: "effort", value: "high" }, + { id: "thinking", value: "true" }, + ], + }); + }); + + it("leaves models without advertised options unchanged", () => { + const selection = { id: "default" }; + expect(withCursorDefaultParameters(selection, undefined)).toBe(selection); + }); +}); + describe("checkCursorProviderStatus", () => { it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 82545a439930..c3f50451912b 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -6,6 +6,7 @@ * * @module provider/Drivers/CursorDriver */ +import type { ModelSelection as CursorSdkModelSelection } from "@cursor/sdk"; import { CursorSettings, defaultInstanceIdForDriver, @@ -34,6 +35,7 @@ import { checkCursorProviderStatus, } from "../CursorProvider.ts"; import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; +import { withCursorDefaultParameters } from "../cursorSdkModel.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -152,6 +154,38 @@ export const CursorDriver: ProviderDriver = { }, }); + // Sends the defaults the model picker shows for anything the selection leaves out. + const withPickerDefaults = ( + model: CursorSdkModelSelection | undefined, + ): Effect.Effect => + model === undefined + ? Effect.succeed(undefined) + : snapshot.getSnapshot.pipe( + Effect.map((provider) => + withCursorDefaultParameters( + model, + provider.models.find((candidate) => candidate.slug === model.id)?.capabilities + ?.optionDescriptors, + ), + ), + ); + + const withSendPickerDefaults = ( + session: CursorAgentSdk.CursorAgentSdkSession, + ): CursorAgentSdk.CursorAgentSdkSession => ({ + ...session, + send: (sendInput) => + withPickerDefaults(sendInput.options?.model).pipe( + Effect.flatMap((model) => + session.send( + model === undefined + ? sendInput + : { ...sendInput, options: { ...sendInput.options, model } }, + ), + ), + ), + }); + const orchestrationAdapter = yield* CursorAdapterV2Driver.create({ instanceId, displayName, @@ -163,15 +197,18 @@ export const CursorDriver: ProviderDriver = { Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { ...sdkRunner, open: (input) => - auth.requireApiKey.pipe( - Effect.flatMap((apiKey) => + Effect.all([auth.requireApiKey, withPickerDefaults(input.options.model)]).pipe( + Effect.flatMap(([apiKey, model]) => Effect.acquireRelease( sdkRunner - .open({ ...input, options: { ...input.options, apiKey } }) + .open({ + ...input, + options: { ...input.options, apiKey, ...(model ? { model } : {}) }, + }) .pipe( Effect.flatMap((session) => Effect.cached(session.close).pipe( - Effect.map((close) => ({ ...session, close })), + Effect.map((close) => withSendPickerDefaults({ ...session, close })), ), ), ), diff --git a/apps/server/src/provider/cursorSdkModel.ts b/apps/server/src/provider/cursorSdkModel.ts index 3332cdbf8671..bc8a845d53d2 100644 --- a/apps/server/src/provider/cursorSdkModel.ts +++ b/apps/server/src/provider/cursorSdkModel.ts @@ -1,5 +1,6 @@ import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk"; -import type { ModelSelection } from "@t3tools/contracts"; +import type { ModelSelection, ProviderOptionDescriptor } from "@t3tools/contracts"; +import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly> = { context: "contextWindow", @@ -48,3 +49,24 @@ export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorS }), }; } + +/** + * Fills the parameters a selection leaves out with the defaults the model + * picker shows. Cursor resolves an omitted parameter to the model's standard + * tier rather than its default variant, so an untouched composer, a delegated + * task or a scheduled run would otherwise get a smaller context window than + * the picker displays. Fast stays off unless chosen, as in the composer. + */ +export function withCursorDefaultParameters( + selection: CursorSdkModelSelection, + descriptors: ReadonlyArray | undefined, +): CursorSdkModelSelection { + const params = selection.params ?? []; + const present = new Set(params.map((parameter) => parameter.id)); + const defaults = (descriptors ?? []).flatMap((descriptor): Array => { + const id = cursorSdkParameterId(descriptor.id); + const value = descriptor.id === "fastMode" ? false : getProviderOptionCurrentValue(descriptor); + return present.has(id) || value === undefined ? [] : [{ id, value: String(value) }]; + }); + return defaults.length === 0 ? selection : { ...selection, params: [...params, ...defaults] }; +} diff --git a/docs/fork-differences.md b/docs/fork-differences.md index fe8198884b90..56edad457671 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -150,6 +150,24 @@ This is a port of upstream PR [#15884](https://github.com/pingdotgg/t3code/pull/ [#15788](https://github.com/pingdotgg/t3code/issues/15788). Remove this section when upstream merges it. Code: `apps/server/src/provider/cursorSdk.ts`. +## Cursor turns send the picker's defaults + +Cursor gets every model option the picker shows, not only the ones the user changed. Cursor treats +an omitted option as the model's standard tier, so upstream runs an untouched composer, a delegated +task, a launched thread, or a scheduled task at about 300K while the picker shows 1M (500K for +Grok 4.7). Fast is the exception: it stays off unless chosen, as in the composer. With the +long-context change above, these defaults run in Max Mode. + +`delegate_task` on the parent's own provider and model keeps the parent's options and overrides +only the ones it names, for every provider. Upstream replaces them, so an effort-only child loses +the parent's context window. + +Fixes upstream [#16149](https://github.com/pingdotgg/t3code/issues/16149); remove this section when +upstream fixes it. Code: `withCursorDefaultParameters` in +`apps/server/src/provider/cursorSdkModel.ts`, applied in +`apps/server/src/provider/Drivers/CursorDriver.ts`, and `resolveTarget` in +`apps/server/src/mcp/OrchestratorMcpService.ts`. + ## Agents panel drilldowns Upstream makes every subagent a child thread, lists a thread's subagents under **Lineage** in the diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index f5b147812254..5f209c662fb5 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -118,12 +118,14 @@ export const OrchestratorMcpTarget = Schema.Struct({ * Model option selections for the child (for example reasoning effort). * Accepts the canonical `[{ id, value }]` array or the shorthand * `{ id: value }` record; valid ids come from the option descriptors - * advertised by orchestrator_capabilities. When omitted, options inherit - * from the parent only when the child runs the parent's provider and model. + * advertised by orchestrator_capabilities. When the child runs the parent's + * provider and model, these override the parent's options by id and the rest + * are inherited; otherwise only these are sent. */ options: Schema.optional( OrchestratorMcpTargetOptions.annotate({ - description: "Model option selections advertised by orchestrator_capabilities.", + description: + "Model option selections advertised by orchestrator_capabilities. On the parent's provider and model, options left out are inherited from the parent.", }), ), });