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
9 changes: 7 additions & 2 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
{ value: "composer-2", name: "Composer 2" },
{ value: "gpt-5.4", name: "GPT-5.4" },
{ value: "claude-opus-4-6", name: "Opus 4.6" },
{ value: "claude-sonnet-4-6", name: "Sonnet 4.6" },
],
},
];
Expand Down Expand Up @@ -254,10 +255,11 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
},
];
case "claude-opus-4-6":
case "claude-sonnet-4-6":
return [
...baseOptions,
{
configId: "reasoning",
configId: currentModelId === "claude-opus-4-6" ? "effort" : "reasoning_effort",
name: "Reasoning",
category: "thought_level",
type: "select",
Expand Down Expand Up @@ -833,7 +835,10 @@ const program = Effect.gen(function* () {
if (request.configId === "agent" && typeof request.value === "string") {
currentAgent = request.value;
}
if (request.configId === "reasoning" && typeof request.value === "string") {
if (
["reasoning", "effort", "reasoning_effort"].includes(request.configId) &&
typeof request.value === "string"
) {
currentReasoning = request.value;
}
if (request.configId === "context" && typeof request.value === "string") {
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,124 @@ describe("AcpRegistryAdapterV2", () => {
}).pipe(Effect.provide(layerTest)),
);

// regression: #24371 Cursor lists only fixed effort presets unless asked for its picker
it.effect("opens Cursor on a plain model with the reasoning effort the thread picked", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const instanceId = ProviderInstanceId.make("acp-registry-cursor");
const openCursor = Effect.fn("openCursor")(function* (stored: ModelSelection) {
let published: AcpRegistryLiveConfiguration | undefined;
const adapter = yield* makeAcpRegistryAdapterV2({
selfInvocation: yield* resolveSelfInvocation(),
instanceId,
settings: yield* decodeAcpRegistryAdapterSettings({ agentId: "cursor" }),
environment: {},
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({
resolve: (_settings, cwd) =>
Effect.succeed({
distribution: "local" as const,
spawn: { command: process.execPath, args: [mockAgentPath], cwd },
}),
}),
Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({
withForegroundStartup: (_startupKey, effect) => effect,
publishLiveConfiguration: (_instanceId, configuration) =>
Effect.sync(() => {
published = configuration;
}),
}),
),
),
);
const threadId = ThreadId.make("thread-acp-registry-cursor");
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
yield* Effect.gen(function* () {
const runtime = yield* adapter.openSession({
threadId: ThreadId.make("thread-acp-registry-cursor"),
providerSessionId: ProviderSessionId.make("provider-session-acp-registry-cursor"),
modelSelection: stored,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection: stored,
runtimePolicy,
});
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now: yield* DateTime.now,
ordinal: 1,
modelSelection: stored,
}),
);
yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runForEach((event) =>
Effect.sync(() => {
if (event.type === "turn.terminal") {
assert.equal(event.status, "completed", event.failure?.message);
}
}),
),
);
}).pipe(Effect.scoped);
return published;
});

const picked = yield* openCursor({
instanceId,
model: "gpt-5.4",
options: [{ id: "reasoning", value: "extra-high" }],
});
assert.equal(picked?.currentModelId, "gpt-5.4");
assert.deepInclude(
picked?.configOptions.find((option) => option.id === "reasoning"),
{ currentValue: "extra-high" },
);
for (const [model, id] of [
["claude-opus-4-6", "effort"],
["claude-sonnet-4-6", "reasoning_effort"],
]) {
const native = yield* openCursor({ instanceId, model: `${model}[${id}=high]` });
assert.equal(native?.currentModelId, model);
assert.deepInclude(
native?.configOptions.find((option) => option.id === id),
{ currentValue: "high" },
);
}
const legacy = yield* openCursor({
instanceId,
model: "gpt-5.4[reasoning=high,context=1m,fast=true]",
options: [{ id: "reasoning", value: "extra-high" }],
});
assert.equal(legacy?.currentModelId, "gpt-5.4");
for (const [id, value] of [
["reasoning", "extra-high"],
["context", "1m"],
["fast", "true"],
]) {
assert.deepInclude(
legacy?.configOptions.find((option) => option.id === id),
{ currentValue: value },
);
}
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

// regression: #26695 cancelling a Copilot prompt kills its background agents
it.effect("never steers Copilot by cancelling its prompt", () =>
Effect.gen(function* () {
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/provider/AcpRegistryProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,45 @@ describe("ACP Registry probe", () => {
);
});

// regression: #24371 Cursor lists only fixed effort presets unless asked for its picker
// Live clock: the probe waits a short real grace for command advertisements.
it.live("lists Cursor's plain model ids so its effort stays a separate option", () => {
const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
search: () => Effect.die("unused search"),
prepare: () => Effect.die("unused prepare"),
inspect: () => Effect.die("unused inspect"),
uninstallManagedBinary: () => Effect.die("unused uninstall"),
resolve: () =>
Effect.succeed({
distribution: "local" as const,
spawn: { command: "node", args: [mockAgentPath] },
}),
});
const probedModels = (agentId: string) =>
probeAcpRegistryConfiguration({
instanceId,
settings: decodeSettings({ agentId }),
cwd: process.cwd(),
environment: process.env,
}).pipe(Effect.map((result) => result.probe.models.map((model) => model.id)));

return Effect.gen(function* () {
expect(yield* probedModels("cursor")).toEqual([
"default",
"composer-2",
"gpt-5.4",
"claude-opus-4-6",
"claude-sonnet-4-6",
]);
expect(yield* probedModels("mock-agent")).toContain(
"gpt-5.3-codex[reasoning=medium,fast=false]",
);
}).pipe(
Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
);
});

it.live.each(["succeeds", "fails"] as const)(
"deletes its health-probe session when deletion is advertised and %s",
(deleteOutcome) =>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/TraitsPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ function getSelectedTraits(
) {
const caps = getProviderModelCapabilities(models, model, provider, includePlanAgent);
const modelIsUnavailable =
(provider === "opencode" || provider === "cursor") &&
(provider === "opencode" || provider === "acpRegistry" || provider === "cursor") &&
!models.some((candidate) => candidate.slug === normalizeModelSlug(model, provider));
const descriptors = modelIsUnavailable
? buildUnavailableModelOptionDescriptors(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/composerProviderState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
promptInjectionState = "none",
includePlanAgent,
} = input;
if (provider === "opencode" || provider === "cursor") {
if (provider === "opencode" || provider === "acpRegistry" || provider === "cursor") {
const normalizedModel = normalizeModelSlug(model, provider);
const modelIsInCatalog = models.some((candidate) => candidate.slug === normalizedModel);
if (!modelIsInCatalog) {
Expand Down
137 changes: 80 additions & 57 deletions apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ function settingsWithProviderInstances(): UnifiedSettings {
return {
...DEFAULT_UNIFIED_SETTINGS,
providerInstances: {
[ProviderInstanceId.make("acpRegistry")]: {
driver: ProviderDriverKind.make("acpRegistry"),
config: { agentId: "cursor" },
},
[ProviderInstanceId.make("claudeAgent")]: {
driver: ProviderDriverKind.make("claudeAgent"),
config: { customModels: [] },
Expand Down Expand Up @@ -399,6 +403,11 @@ describe("instance-scoped model selection", () => {
availableModel: "opencode/big-pickle",
missingModel: "opencode/kimi-k3",
},
{
driverName: "acpRegistry",
availableModel: "composer-2",
missingModel: "composer-2[fast=true]",
},
{
driverName: "antigravity",
availableModel: "gemini-3.1-pro",
Expand Down Expand Up @@ -484,37 +493,40 @@ describe("instance-scoped model selection", () => {
});
});

it("falls back from an explicit non-OpenCode draft with a missing model", () => {
const instanceId = ProviderInstanceId.make("codex");
const driver = ProviderDriverKind.make("codex");
const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })];
const state = deriveEffectiveComposerModelState({
threadStarted: false,
draft: {
activeProvider: instanceId,
modelSelectionByProvider: {
[instanceId]: createModelSelection(instanceId, "gpt-missing", [
{ id: "effort", value: "max" },
]),
it.each(["codex", "acpRegistry"])(
"falls back from an explicit %s draft with a missing model",
(driverName) => {
const instanceId = ProviderInstanceId.make(driverName);
const driver = ProviderDriverKind.make(driverName);
const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })];
const state = deriveEffectiveComposerModelState({
threadStarted: false,
draft: {
activeProvider: instanceId,
modelSelectionByProvider: {
[instanceId]: createModelSelection(instanceId, "gpt-missing", [
{ id: "effort", value: "max" },
]),
},
},
},
providers,
selectedProvider: driver,
selectedInstanceId: instanceId,
threadModelSelection: null,
projectModelSelection: null,
settings: settingsWithProviderInstances(),
});
const dispatch = getComposerProviderState({
provider: driver,
model: state.selectedModel,
models: providers[0]!.models,
modelOptions: state.modelOptions?.[instanceId],
});
providers,
selectedProvider: driver,
selectedInstanceId: instanceId,
threadModelSelection: null,
projectModelSelection: null,
settings: settingsWithProviderInstances(),
});
const dispatch = getComposerProviderState({
provider: driver,
model: state.selectedModel,
models: providers[0]!.models,
modelOptions: state.modelOptions?.[instanceId],
});

expect(state.selectedModel).toBe("gpt-5.6-sol");
expect(dispatch.modelOptionsForDispatch).toBeUndefined();
});
expect(state.selectedModel).toBe("gpt-5.6-sol");
expect(dispatch.modelOptionsForDispatch).toBeUndefined();
},
);

it("sends a Cursor thread's stored model and options when the catalog lacks them", () => {
const instanceId = ProviderInstanceId.make("cursor");
Expand Down Expand Up @@ -723,35 +735,46 @@ describe("instance-scoped model selection", () => {
).toEqual([]);
});

it("preserves saved options through dispatch when the model is absent from the catalog", () => {
const instanceId = ProviderInstanceId.make("opencode");
const driver = ProviderDriverKind.make("opencode");
const providers = [provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] })];
const saved = createModelSelection(instanceId, "opencode/kimi-k3", [
{ id: "variant", value: "max" },
{ id: "agent", value: "build" },
]);
const state = deriveEffectiveComposerModelState({
threadStarted: false,
draft: null,
providers,
selectedProvider: driver,
selectedInstanceId: instanceId,
threadModelSelection: saved,
projectModelSelection: null,
settings: settingsWithProviderInstances(),
});
const dispatch = getComposerProviderState({
provider: driver,
model: state.selectedModel,
models: providers[0]!.models,
modelOptions: state.modelOptions?.[instanceId],
});
it.each([
[
"opencode",
"opencode/big-pickle",
"opencode/kimi-k3",
[
{ id: "variant", value: "max" },
{ id: "agent", value: "build" },
],
],
["acpRegistry", "composer-2", "composer-2[fast=true]", [{ id: "fast", value: "false" }]],
])(
"preserves saved %s options through dispatch when the model is absent from the catalog",
(driverName, availableModel, missingModel, options) => {
const instanceId = ProviderInstanceId.make(driverName);
const driver = ProviderDriverKind.make(driverName);
const providers = [provider({ provider: driver, instanceId, models: [availableModel] })];
const saved = createModelSelection(instanceId, missingModel, options);
const state = deriveEffectiveComposerModelState({
threadStarted: false,
draft: null,
providers,
selectedProvider: driver,
selectedInstanceId: instanceId,
threadModelSelection: saved,
projectModelSelection: null,
settings: settingsWithProviderInstances(),
});
const dispatch = getComposerProviderState({
provider: driver,
model: state.selectedModel,
models: providers[0]!.models,
modelOptions: state.modelOptions?.[instanceId],
});

expect(
createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch),
).toEqual(saved);
});
expect(
createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch),
).toEqual(saved);
},
);

describe("composer model option precedence", () => {
const instanceId = ProviderInstanceId.make("claudeAgent");
Expand Down
Loading
Loading