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