Skip to content
Merged
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
95 changes: 95 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyArray<unknown>>([]);
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))));
}
}),
);
});
26 changes: 20 additions & 6 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
});

Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/provider/CursorProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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* () {
Expand Down
45 changes: 41 additions & 4 deletions apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @module provider/Drivers/CursorDriver
*/
import type { ModelSelection as CursorSdkModelSelection } from "@cursor/sdk";
import {
CursorSettings,
defaultInstanceIdForDriver,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -152,6 +154,38 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
},
});

// Sends the defaults the model picker shows for anything the selection leaves out.
const withPickerDefaults = (
model: CursorSdkModelSelection | undefined,
): Effect.Effect<CursorSdkModelSelection | undefined> =>
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,
Expand All @@ -163,15 +197,18 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
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 })),
),
),
),
Expand Down
24 changes: 23 additions & 1 deletion apps/server/src/provider/cursorSdkModel.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
context: "contextWindow",
Expand Down Expand Up @@ -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<ProviderOptionDescriptor> | undefined,
): CursorSdkModelSelection {
const params = selection.params ?? [];
const present = new Set(params.map((parameter) => parameter.id));
const defaults = (descriptors ?? []).flatMap((descriptor): Array<ModelParameterValue> => {
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] };
}
18 changes: 18 additions & 0 deletions docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions packages/contracts/src/orchestratorMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}),
),
});
Expand Down