From 01cef552f97bc1326f19deb4ec892c0e1076f79d Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:24:04 +0000 Subject: [PATCH] fix(chat): keep Cursor questions and plans with the person, list models over ACP Cursor's question and plan requests name no session, so Orca rejected them and Cursor fell back to a permission request whose answers are all allow_once, which Full access answered for the person. Route them to the chat and show the fallback as a question. Read models from cursor/list_available_models with each model's effort levels and Fast support, pick the effort menu over Claude's on/off thinking switch, pin CURSOR_CONFIG_DIR the way Cursor resolves it, and skip authenticate, which opens a browser while signed out. --yolo does nothing over ACP, so Full access answers each request instead. Only Claude treats an unpicked Fast as off. --- src/main/acp/acp-dialects/acp-dialect.ts | 4 +- src/main/acp/acp-dialects/cursor-dialect.ts | 160 +++++++ .../acp/acp-dialects/cursor-model-catalog.ts | 45 ++ src/main/acp/launch/acp-account-binding.ts | 8 +- .../acp/options/acp-projected-model-menu.ts | 8 +- src/main/acp/options/acp-select-choices.ts | 15 + .../acp/options/acp-structured-options.ts | 16 +- .../acp/requests/acp-timeline-requests.ts | 3 +- .../acp/session/acp-structured-cursor.test.ts | 395 ++++++++++++------ .../turns/acp-message-execution-options.ts | 7 +- .../claude-structured-agent-definition.ts | 3 +- src/main/cursor/cursor-acp-launch-spec.ts | 92 ++-- ...uctured-agent-session-execution-options.ts | 4 +- ...tructured-agent-session-resting-options.ts | 2 +- .../providers/structured-agent-definition.ts | 2 + .../naming/structured-chat-naming-runtime.ts | 2 +- .../agent-chat-permission-support.ts | 1 + .../lifecycle/agent-session-setup-commands.ts | 6 +- src/shared/cursor-acp-model-label.ts | 66 --- 19 files changed, 550 insertions(+), 289 deletions(-) create mode 100644 src/main/acp/acp-dialects/cursor-dialect.ts create mode 100644 src/main/acp/acp-dialects/cursor-model-catalog.ts delete mode 100644 src/shared/cursor-acp-model-label.ts diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 72f9b3dfbfb..b33c652e774 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -160,6 +160,8 @@ export type AcpDialect = { clampsUnlistedEffort?: true /** Opt-in because advertising forms changes agent behaviour; requests use the generic handlers. */ clientCapabilities?: Pick + /** Requests that name no session; they belong to the chat's own. */ + sessionlessRequests?: readonly string[] /** Claimed permission requests are never auto-approved. */ request?(method: string, params: unknown): AcpRequestPresentation | undefined settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined @@ -193,8 +195,6 @@ export type AcpDialect = { signInAccount?: AgentSessionAccountKind /** Which session rows are the agent's own built-ins, as this process's initialize response proves. */ builtinCommands?(initialized: InitializeResponse): (command: AvailableCommand) => boolean - /** A selector the menu names too narrowly, such as Cursor's parameterized model id. */ - modelChoiceLabel?(value: string, name: string): string /** This agent's fast parameter, mapped to Orca's fast mode toggle. */ fastConfigId?: string } diff --git a/src/main/acp/acp-dialects/cursor-dialect.ts b/src/main/acp/acp-dialects/cursor-dialect.ts new file mode 100644 index 00000000000..d8693430a6a --- /dev/null +++ b/src/main/acp/acp-dialects/cursor-dialect.ts @@ -0,0 +1,160 @@ +import { z } from 'zod' +import { + isValidAgentSessionQuestionAnswers, + legacyAgentSessionQuestionAnswers +} from '../../../shared/agent-session/agent-session-question-answer' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import { readAcpPermissionRequest } from '../protocol/acp-permission-requests' +import { AcpRpcError } from '../transport/acp-errors' +import { + pendingAcpResolution, + type AcpDialect, + type AcpRequestPresentation, + type AcpRequestSettlement +} from './acp-dialect' + +const ASK_QUESTION = 'cursor/ask_question' +const CREATE_PLAN = 'cursor/create_plan' +// Reports Cursor sends as requests; it ignores the reply, and plan updates already carry the todos. +const REPORTS = ['cursor/update_todos', 'cursor/task', 'cursor/generate_image'] +// A refused question comes back as a permission request whose answers are all allow_once. +const QUESTION_FALLBACK_SKIP = '__ask_question_skip__' + +const questionRequestSchema = z.object({ + title: z.string().nullish(), + questions: z + .array( + z.object({ + id: z.string(), + prompt: z.string(), + allowMultiple: z.boolean().nullish(), + options: z.array(z.object({ id: z.string(), label: z.string() })).min(1) + }) + ) + .min(1) +}) +const planRequestSchema = z.object({ plan: z.string().nullish(), overview: z.string().nullish() }) + +function questionRequest(params: unknown): AcpRequestPresentation { + const parsed = questionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent question request') + } + const questions = parsed.data.questions.map((question) => ({ + id: question.id, + question: question.prompt, + multiSelect: question.allowMultiple ?? false, + options: question.options + })) + const body = { + kind: 'question' as const, + question: questions[0]!.question, + options: questions[0]!.options, + questions, + resolution: pendingAcpResolution + } + return { + body, + reply: (response) => { + if (response === null) { + return { outcome: { outcome: 'cancelled' } } + } + const answers = + response.kind === 'answers' + ? response.answers + : legacyAgentSessionQuestionAnswers(body, response.optionId) + if (!answers || !isValidAgentSessionQuestionAnswers(questions, answers)) { + throw new AcpRpcError(-32602, 'Question answer must answer every offered question') + } + return { + outcome: { + outcome: 'answered', + answers: answers.map((answer) => ({ + questionId: answer.questionId, + selectedOptionIds: answer.optionIds + })) + } + } + } + } +} + +// Shown as a question, so Full access never picks the first answer for the person. +function fallbackQuestionRequest(params: unknown): AcpRequestPresentation | undefined { + const request = readAcpPermissionRequest(params, () => {}) + if (!request?.options.some((option) => option.optionId === QUESTION_FALLBACK_SKIP)) { + return undefined + } + const question = request.toolCall.title ?? 'The agent has a question' + const options = request.options.map((option) => ({ id: option.optionId, label: option.name })) + return { + body: { kind: 'question', question, options, resolution: pendingAcpResolution }, + reply: (response) => { + if (response === null) { + return { outcome: { outcome: 'cancelled' } } + } + const optionId = + response.kind === 'option' ? response.optionId : response.answers[0]?.optionIds[0] + if (!options.some((option) => option.id === optionId)) { + throw new AcpRpcError(-32602, 'Question answer must select an offered option') + } + return { outcome: { outcome: 'selected', optionId } } + } + } +} + +function settleRequest(method: string, params: unknown): AcpRequestSettlement | undefined { + if (REPORTS.includes(method)) { + return { reply: {} } + } + if (method !== CREATE_PLAN) { + return undefined + } + const parsed = planRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent plan request') + } + // Accepting saves Cursor's plan file and ends the turn; building waits for the next message. + return { + reply: { outcome: { outcome: 'accepted' } }, + plan: + parsed.data.plan?.trim() || + parsed.data.overview?.trim() || + 'The agent finished planning without writing a plan.' + } +} + +const NEW_FILE_OLD_TEXT = '-- /dev/null' +const NEW_FILE_HEADER = /^\+\+ b\/[^\n]*\n?/u + +// New files arrive as `-- /dev/null` and a `++ b/` line ahead of the content. +function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { + if ( + !update.content?.some((block) => block.type === 'diff' && block.oldText === NEW_FILE_OLD_TEXT) + ) { + return update + } + return { + ...update, + content: update.content.map((block) => + block.type === 'diff' && block.oldText === NEW_FILE_OLD_TEXT + ? { ...block, oldText: null, newText: block.newText.replace(NEW_FILE_HEADER, '') } + : block + ) + } +} + +export const CURSOR_ACP_DIALECT: AcpDialect = { + // Model, effort and fast arrive as separate options instead of one selector per variant. + clientCapabilities: { _meta: { parameterizedModelPicker: true } }, + fastConfigId: 'fast', + sessionlessRequests: [ASK_QUESTION, CREATE_PLAN, ...REPORTS], + request: (method, params) => + method === ASK_QUESTION + ? questionRequest(params) + : method === 'session/request_permission' + ? fallbackQuestionRequest(params) + : undefined, + settleRequest, + normalizeToolUpdate +} diff --git a/src/main/acp/acp-dialects/cursor-model-catalog.ts b/src/main/acp/acp-dialects/cursor-model-catalog.ts new file mode 100644 index 00000000000..e76a6b20620 --- /dev/null +++ b/src/main/acp/acp-dialects/cursor-model-catalog.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' +import type { AgentSessionModelOption } from '../../../shared/agent-session/agent-session-wire' +import { SessionConfigOptionSchema } from '../generated/acp-protocol.generated' +import { acpEffortOption, acpSelectChoices } from '../options/acp-select-choices' + +const listReplySchema = z.object({ + models: z + .array( + z.object({ + value: z.string(), + name: z.string(), + configOptions: z.array(z.unknown()).nullish() + }) + ) + .min(1) +}) + +/** Lists every model with its own effort levels and Fast support, before any session exists. */ +export async function readCursorModelCatalog(connection: { + requestSessionFreeExtension(method: string, params: unknown): Promise +}): Promise { + const reply = listReplySchema.safeParse( + await connection.requestSessionFreeExtension('cursor/list_available_models', {}) + ) + if (!reply.success) { + throw new Error('cursor did not list its models') + } + return reply.data.models.map((model) => { + const options = (model.configOptions ?? []).flatMap( + (raw) => SessionConfigOptionSchema.safeParse(raw).data ?? [] + ) + const effort = acpEffortOption(options) + return { + id: model.value, + label: model.name, + // currentValue is each model's own default, never the account's saved choice. + isDefault: false, + efforts: effort + ? acpSelectChoices(effort).map((choice) => ({ value: choice.value, label: choice.name })) + : [], + ...(effort ? { defaultEffort: effort.currentValue } : {}), + supportsFastMode: options.some((option) => option.id === 'fast') + } + }) +} diff --git a/src/main/acp/launch/acp-account-binding.ts b/src/main/acp/launch/acp-account-binding.ts index 048e45ac9d8..00e8217c215 100644 --- a/src/main/acp/launch/acp-account-binding.ts +++ b/src/main/acp/launch/acp-account-binding.ts @@ -28,13 +28,17 @@ export type AcpAccountBinding = { export function directoryAccountBinding( variable: string, - defaultHome: (homePath: string) => string + defaultHome: (homePath: string, launchEnv: NodeJS.ProcessEnv) => string ): AcpAccountBinding { return { pin: { accountHomeVariable: variable }, resolve: async ({ launchEnv }) => ({ variable, - path: resolveStructuredEnvAccountHomePath({ launchEnv, variable, defaultPath: defaultHome }) + path: resolveStructuredEnvAccountHomePath({ + launchEnv, + variable, + defaultPath: (homePath) => defaultHome(homePath, launchEnv) + }) }), environment: (home, env) => { if (!isLegacyAgentSessionAccountHome(home) || home.variable !== variable) { diff --git a/src/main/acp/options/acp-projected-model-menu.ts b/src/main/acp/options/acp-projected-model-menu.ts index 734fc98a9d8..5997bbd48ad 100644 --- a/src/main/acp/options/acp-projected-model-menu.ts +++ b/src/main/acp/options/acp-projected-model-menu.ts @@ -10,7 +10,7 @@ import type { SessionConfigOption, SessionModelState } from '../generated/acp-pr type SelectOption = Extract export function flatAcpModelMenu(input: { - dialect: Pick + dialect: Pick modelOption: SelectOption | null effortOption: SelectOption | null availableModels: SessionModelState['availableModels'] | undefined @@ -25,17 +25,15 @@ export function flatAcpModelMenu(input: { ...(choice.description ? { description: choice.description } : {}) })) : [] - const labelFor = (value: string, name: string): string => - input.dialect.modelChoiceLabel?.(value, name) ?? name const listed: { id: string; label: string; description?: string }[] = input.modelOption ? acpSelectChoices(input.modelOption).map((choice) => ({ id: choice.value, - label: labelFor(choice.value, choice.name), + label: choice.name, ...(choice.description ? { description: choice.description } : {}) })) : (input.availableModels?.map((model) => ({ id: model.modelId, - label: labelFor(model.modelId, model.name), + label: model.name, ...(model.description ? { description: model.description } : {}) })) ?? []) const models: AgentSessionModelOption[] = listed.map((model) => { diff --git a/src/main/acp/options/acp-select-choices.ts b/src/main/acp/options/acp-select-choices.ts index 7724d86f87f..1f590d41920 100644 --- a/src/main/acp/options/acp-select-choices.ts +++ b/src/main/acp/options/acp-select-choices.ts @@ -22,3 +22,18 @@ export function acpSelectChoices(option: SelectOption): SessionConfigSelectOptio } return choices } + +/** An agent may list an on/off thinking switch beside its effort levels; effort is the longer menu. */ +export function acpEffortOption(options: readonly SessionConfigOption[]): SelectOption | null { + let effort: SelectOption | null = null + for (const option of options) { + if ( + option.category === 'thought_level' && + option.type === 'select' && + (!effort || acpSelectChoices(option).length > acpSelectChoices(effort).length) + ) { + effort = option + } + } + return effort +} diff --git a/src/main/acp/options/acp-structured-options.ts b/src/main/acp/options/acp-structured-options.ts index 23930510d9d..a0149e1f0be 100644 --- a/src/main/acp/options/acp-structured-options.ts +++ b/src/main/acp/options/acp-structured-options.ts @@ -1,4 +1,4 @@ -import { acpSelectChoices } from './acp-select-choices' +import { acpEffortOption, acpSelectChoices } from './acp-select-choices' import { flatAcpModelMenu, withFastModeOption } from './acp-projected-model-menu' import { acpInitializeForkSupport } from '../capabilities/acp-fork-support' import type { AgentSessionSlashCommand } from '../../../shared/agent-session/agent-session-wire' @@ -154,7 +154,7 @@ export class AcpStructuredOptions { if (this.select('model')) { this.unobservedCurrent.delete('model') } - if (this.select('thought_level')) { + if (this.effortOption()) { this.unobservedCurrent.delete('effort') } } @@ -207,7 +207,7 @@ export class AcpStructuredOptions { } offersEffort(value: string): boolean { - const option = this.select('thought_level') + const option = this.effortOption() return !!( option && (this.dialect.clampsUnlistedEffort || @@ -228,7 +228,7 @@ export class AcpStructuredOptions { return this.models ? { method: 'model', modelId: value } : null } if (key === 'effort') { - const option = this.select('thought_level') + const option = this.effortOption() return option ? { method: 'config', configId: option.id, value } : null } return null @@ -240,7 +240,7 @@ export class AcpStructuredOptions { 'models' | 'current' | 'forkSupport' | 'fastModeSupport' > { const modelOption = this.select('model') - const effortOption = this.select('thought_level') + const effortOption = this.effortOption() const currentModel = (modelOption?.currentValue ?? this.models?.currentModelId) || undefined return withFastModeOption( flatAcpModelMenu({ @@ -304,10 +304,14 @@ export class AcpStructuredOptions { private currentValues(): Readonly> { return { model: (this.select('model')?.currentValue ?? this.models?.currentModelId) || undefined, - effort: this.select('thought_level')?.currentValue + effort: this.effortOption()?.currentValue } } + private effortOption(): SelectOption | null { + return acpEffortOption(this.configOptions) + } + private fastOption(): SelectOption | null { const id = this.dialect.fastConfigId if (!id) { diff --git a/src/main/acp/requests/acp-timeline-requests.ts b/src/main/acp/requests/acp-timeline-requests.ts index c2a08f45ec9..60bce5c2654 100644 --- a/src/main/acp/requests/acp-timeline-requests.ts +++ b/src/main/acp/requests/acp-timeline-requests.ts @@ -71,7 +71,8 @@ export function translateAcpRequest( settled?: { reply: unknown } } { const session = requestSessionSchema.safeParse(params) - if (!session.success || session.data.sessionId !== options.sessionId) { + const sessionless = options.dialect.sessionlessRequests?.includes(method) && !session.success + if (!sessionless && (!session.success || session.data.sessionId !== options.sessionId)) { throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') } const tool = requestToolSchema.safeParse(params) diff --git a/src/main/acp/session/acp-structured-cursor.test.ts b/src/main/acp/session/acp-structured-cursor.test.ts index 29cf8754649..a65ebc1e33f 100644 --- a/src/main/acp/session/acp-structured-cursor.test.ts +++ b/src/main/acp/session/acp-structured-cursor.test.ts @@ -1,142 +1,279 @@ import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { closeProviderTimelineRigs } from '../../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + closeProviderTimelineRigs, + SESSION +} from '../../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { AcpStructuredOptions } from '../options/acp-structured-options' import { acpLaunchSpecFor } from '../launch/acp-launch-registry' -import { resolveAcpLaunchInvocation } from '../launch/acp-structured-launch-resolution' -import { openAcpAdapterRig, PROVIDER_SESSION } from './testing/acp-structured-adapter.test-support' +import { readCursorModelCatalog } from '../acp-dialects/cursor-model-catalog' +import type { SessionConfigOption } from '../generated/acp-protocol.generated' +import type { AcpScriptedAgent } from '../process/testing/acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + sendHello, + waitFor +} from './testing/acp-structured-adapter.test-support' const CURSOR = acpLaunchSpecFor('cursor')! afterEach(closeProviderTimelineRigs) -it('launches cursor-agent acp and signs in with the existing Cursor login', async () => { +// Recorded from cursor-agent 2026.11.11 over `cursor-agent acp`, which names no session on these. +const QUESTION = { + toolCallId: 'call-1\nfc_1', + title: 'Preferences', + questions: [ + { + id: 'color', + prompt: 'Which color?', + options: [ + { id: 'red', label: 'Red' }, + { id: 'blue', label: 'Blue' } + ], + allowMultiple: false + } + ] +} +const QUESTION_FALLBACK = { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1\nfc_1_q0', title: 'Which color?', kind: 'other' }, + options: [ + { optionId: 'red', name: 'Red', kind: 'allow_once' }, + { optionId: 'blue', name: 'Blue', kind: 'allow_once' }, + { optionId: '__ask_question_skip__', name: 'Skip', kind: 'reject_once' } + ] +} +const OPUS_OPTIONS: SessionConfigOption[] = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'claude-opus-4-8', + options: [ + { value: 'default', name: 'Auto' }, + { value: 'claude-opus-4-8', name: 'Claude Opus 4.8' } + ] + }, + { + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'true', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' } + ] + }, + { + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + { value: 'max', name: 'Max' } + ] + }, + { + id: 'fast', + name: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'Fast' } + ] + } +] + +async function fullAccessRig(request: (agent: AcpScriptedAgent) => Promise) { + const replies: unknown[] = [] + const rig = await openAcpAdapterRig({ + spec: CURSOR, + launch: { fullAccess: true }, + script: (agent) => + agent.on('session/prompt', () => { + void request(agent).then((reply) => replies.push(reply)) + }) + }) + await rig.acquire({ permissionMode: 'full-access' }) + await sendHello(rig, 'send-1') + return { rig, replies } +} + +it("asks the person Cursor's question in Full access and answers it in Cursor's shape", async () => { + const { rig, replies } = await fullAccessRig((agent) => + agent.request(7, 'cursor/ask_question', QUESTION) + ) + const question = await waitFor(async () => { + const found = (await rig.rig.rows()).find((row) => row.body.kind === 'question') + expect(found).toBeDefined() + return found! + }) + expect(replies).toEqual([]) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: question.itemId, + kind: 'question', + response: { kind: 'answers', answers: [{ questionId: 'color', optionIds: ['blue'] }] }, + fence: 1, + commit: async () => {} + }) + await waitFor(() => + expect(replies).toEqual([ + expect.objectContaining({ + result: { + outcome: { + outcome: 'answered', + answers: [{ questionId: 'color', selectedOptionIds: ['blue'] }] + } + } + }) + ]) + ) +}) + +it("never picks the first answer of Cursor's fallback question in Full access", async () => { + const { rig, replies } = await fullAccessRig((agent) => + agent.request(8, 'session/request_permission', QUESTION_FALLBACK) + ) + await waitFor(async () => + expect((await rig.rig.rows()).some((row) => row.body.kind === 'question')).toBe(true) + ) + expect(replies).toEqual([]) +}) + +it("shows Cursor's plan and accepts it, which ends the turn without building", async () => { + const { rig, replies } = await fullAccessRig((agent) => + agent.request(9, 'cursor/create_plan', { + toolCallId: 'call-2\nfc_2', + name: 'Add README.md', + overview: 'Add a one-line README.md.', + plan: '# Add README.md\n\nCreate README.md with one line.', + todos: [], + isProject: false, + phases: [] + }) + ) + await waitFor(() => + expect(replies).toEqual([ + expect.objectContaining({ result: { outcome: { outcome: 'accepted' } } }) + ]) + ) + expect((await rig.rig.rows()).map((row) => row.body)).toContainEqual( + expect.objectContaining({ + presentation: 'plan-document', + text: '# Add README.md\n\nCreate README.md with one line.' + }) + ) +}) + +it('writes effort to the effort levels, not the thinking switch, and Fast to its own option', () => { + const options = new AcpStructuredOptions(CURSOR.dialect) + options.adoptSession({ configOptions: OPUS_OPTIONS }, 'new') + expect(options.write('effort', 'max')).toEqual({ + method: 'config', + configId: 'effort', + value: 'max' + }) + expect(options.write('fastMode', 'true')).toEqual({ + method: 'config', + configId: 'fast', + value: 'true' + }) + expect(options.read().current).toMatchObject({ + model: 'claude-opus-4-8', + effort: 'high', + fastMode: false + }) +}) + +it("lists each model's own effort levels and Fast support before a chat exists", async () => { + const models = await readCursorModelCatalog({ + requestSessionFreeExtension: async (method) => { + expect(method).toBe('cursor/list_available_models') + return { + models: [ + { value: 'default', name: 'Auto', configOptions: [] }, + { + value: 'claude-opus-4-8', + name: 'Claude Opus 4.8', + configOptions: OPUS_OPTIONS.slice(1) + }, + { value: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', configOptions: [] } + ] + } + } + }) + expect(models).toEqual([ + { id: 'default', label: 'Auto', isDefault: false, efforts: [], supportsFastMode: false }, + { + id: 'claude-opus-4-8', + label: 'Claude Opus 4.8', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' } + ], + defaultEffort: 'high', + supportsFastMode: true + }, + { + id: 'gemini-3.1-pro', + label: 'Gemini 3.1 Pro', + isDefault: false, + efforts: [], + supportsFastMode: false + } + ]) +}) + +it("drops Cursor's new-file markers from an edit's diff", () => { + const update = CURSOR.dialect.normalizeToolUpdate!({ + toolCallId: 'call-3\nfc_3', + status: 'completed', + content: [ + { + type: 'diff', + path: '/tmp/cursor-ws/new.txt', + oldText: '-- /dev/null', + newText: '++ b//tmp/cursor-ws/new.txt\nhi' + } + ] + }) + expect(update.content).toEqual([ + { type: 'diff', path: '/tmp/cursor-ws/new.txt', oldText: null, newText: 'hi' } + ]) +}) + +it("pins Cursor's own config folder, following XDG_CONFIG_HOME as Cursor does", async () => { + const stateDirectory = '/state' + expect( + await CURSOR.account.resolve({ launchEnv: { HOME: '/home/ada' }, stateDirectory }) + ).toEqual({ variable: 'CURSOR_CONFIG_DIR', path: expect.stringMatching(/[\\/]\.cursor$/) }) + expect( + await CURSOR.account.resolve({ + launchEnv: { XDG_CONFIG_HOME: '/home/ada/.config' }, + stateDirectory + }) + ).toEqual({ variable: 'CURSOR_CONFIG_DIR', path: join('/home/ada/.config', 'cursor') }) +}) + +it('closes a signed-out chat without opening a browser sign-in', async () => { const rig = await openAcpAdapterRig({ spec: CURSOR, initialize: { authMethods: [{ id: 'cursor_login', name: 'Cursor Login' }] }, - script: (agent) => { - agent.on('session/new', (frame) => - agent.frames.some((sent) => sent.method === 'authenticate') - ? agent.reply(frame, { sessionId: PROVIDER_SESSION }) - : agent.fail(frame, -32000, 'Authentication required') - ) - agent.on('authenticate', (frame) => agent.reply(frame, {})) - } + script: (agent) => + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) - await rig.acquire() - expect(rig.sent('authenticate')[0]?.params).toEqual({ methodId: 'cursor_login' }) - expect(CURSOR.loginCommand).toEqual(['cursor-agent', 'login']) -}) - -it('sets Cursor effort and fast as their own options, and lists base models before a chat exists', () => { - const options = new AcpStructuredOptions(CURSOR.dialect) - options.adoptSession( - { - configOptions: [ - { - id: 'model', - name: 'Model', - category: 'model', - type: 'select', - currentValue: 'grok-4.7', - options: [ - { value: 'default', name: 'Auto' }, - { value: 'grok-4.7', name: 'Grok 4.7' } - ] - }, - { - id: 'reasoning_effort', - name: 'Effort', - category: 'thought_level', - type: 'select', - currentValue: 'medium', - options: [ - { value: 'low', name: 'Low' }, - { value: 'medium', name: 'Medium' }, - { value: 'high', name: 'High' } - ] - }, - { - id: 'fast', - name: 'Fast', - category: 'model_config', - type: 'select', - currentValue: 'true', - options: [ - { value: 'false', name: 'Off' }, - { value: 'true', name: 'Fast' } - ] - } - ] - }, - 'new' - ) - expect(options.read().models.map((model) => [model.id, model.label])).toEqual([ - ['default', 'Auto'], - ['grok-4.7', 'Grok 4.7'] - ]) - expect(options.read().current).toMatchObject({ - model: 'grok-4.7', - effort: 'medium', - fastMode: true - }) - expect(options.write('effort', 'low')).toEqual({ - method: 'config', - configId: 'reasoning_effort', - value: 'low' - }) - expect(options.write('fastMode', 'false')).toEqual({ - method: 'config', - configId: 'fast', - value: 'false' - }) - expect(CURSOR.dialect.clientCapabilities).toEqual({ - _meta: { parameterizedModelPicker: true } - }) - expect(CURSOR.modelDiscovery).toMatchObject({ kind: 'command', args: ['models'] }) - if (CURSOR.modelDiscovery.kind !== 'command') { - return - } - expect( - CURSOR.modelDiscovery.parse( - 'auto - Auto (default)\ngrok-4.7-medium - Grok 4.7 Medium\ngrok-4.7-high-fast - Grok 4.7 High Fast\n' - ) - ).toEqual([ - { id: 'default', label: 'Auto', isDefault: true, efforts: [] }, - { id: 'grok-4.7', label: 'Grok 4.7', isDefault: false, efforts: [] } - ]) -}) - -it('grants full access with --yolo and keeps the host Cursor login out of the child environment', async () => { - expect(CURSOR.args({ fullAccess: false, pluginDir: null })).toEqual([ - '--disable-auto-update', - 'acp' - ]) - expect(CURSOR.args({ fullAccess: true, pluginDir: null })).toEqual([ - '--disable-auto-update', - '--yolo', - 'acp' - ]) - const home = await CURSOR.account.resolve({ - launchEnv: { HOME: '/Users/ada' }, - stateDirectory: '/state' - }) - expect(home).toEqual({ - variable: 'ORCA_CURSOR_CONFIG_DIR', - path: join('/Users/ada', '.cursor') - }) - const launch = await resolveAcpLaunchInvocation(CURSOR, home, { - dataDirectory: '/isolated', - inheritedEnv: { ORCA_CURSOR_CONFIG_DIR: join('/Users/ada', '.cursor') }, - resolveEnvironment: async () => ({ - HOME: '/Users/ada', - PATH: '/bin', - ORCA_CURSOR_CONFIG_DIR: join('/Users/ada', '.cursor') - }), - resolveCommand: () => '/bin/cursor-agent' - }) - expect(launch.env).toEqual({ HOME: '/Users/ada', PATH: '/bin' }) - expect(launch.envToDelete).toEqual(['ORCA_CURSOR_CONFIG_DIR']) - expect(() => CURSOR.account.environment({ variable: 'GROK_HOME', path: '/x' }, {})).toThrow( - "this chat uses the host's Cursor login" - ) + await rig.acquire().catch(() => {}) + expect(rig.sent('authenticate')).toEqual([]) + expect(rig.openVerdict()).toMatchObject({ fact: { kind: 'notSignedIn' } }) }) diff --git a/src/main/acp/turns/acp-message-execution-options.ts b/src/main/acp/turns/acp-message-execution-options.ts index 47508aec7a4..1e2c9b25a5b 100644 --- a/src/main/acp/turns/acp-message-execution-options.ts +++ b/src/main/acp/turns/acp-message-execution-options.ts @@ -32,9 +32,10 @@ export async function applyAcpMessageOptions( if ( value === undefined || session.options.appliedInput.get(key) === value || - session.options.matchesNativeRestoredOption(key, value) || - (key === 'effort' && !session.options.offersEffort(value)) || - (key === 'fastMode' && !session.options.write(key, value)) + (key === 'fastMode' + ? !session.options.write(key, value) + : session.options.matchesNativeRestoredOption(key, value) || + (key === 'effort' && !session.options.offersEffort(value))) ) { continue } diff --git a/src/main/claude/structured/claude-structured-agent-definition.ts b/src/main/claude/structured/claude-structured-agent-definition.ts index c1bef215b6c..1d7d07cdebd 100644 --- a/src/main/claude/structured/claude-structured-agent-definition.ts +++ b/src/main/claude/structured/claude-structured-agent-definition.ts @@ -22,6 +22,7 @@ export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { restingOptions: { acceptsKey: isClaudeStructuredOptionKey, fallbackModels: claudeFallbackModelOptions, - effortDefaultsToModel: true + effortDefaultsToModel: true, + fastModeDefaultsOff: true } } diff --git a/src/main/cursor/cursor-acp-launch-spec.ts b/src/main/cursor/cursor-acp-launch-spec.ts index 32d0c752f21..23bb82ee350 100644 --- a/src/main/cursor/cursor-acp-launch-spec.ts +++ b/src/main/cursor/cursor-acp-launch-spec.ts @@ -1,90 +1,46 @@ -// Cursor chats run the host's `cursor-agent acp` against the login already on that computer. - import { join } from 'node:path' -import { homedir } from 'node:os' import type { AcpLaunchSpec } from '../acp/launch/acp-launch-contract' -import type { AcpDialect } from '../acp/acp-dialects/acp-dialect' -import type { AgentSessionModelOption } from '../../shared/agent-session/agent-session-wire' -import { groupCursorCliModels } from '../../shared/cursor-acp-model-label' -import { parseCursorModels } from '../../shared/source-control/ai/generation/commit-message-model-parsers' -import type { StructuredAgentSessionHistory } from '../native-chat/structured-agent-cli-conversations' -import { - isLegacyAgentSessionAccountHome, - type AgentSessionAccountHome -} from '../../shared/agent-session/lifecycle/agent-session-account-home' +import { CURSOR_ACP_DIALECT } from '../acp/acp-dialects/cursor-dialect' +import { readCursorModelCatalog } from '../acp/acp-dialects/cursor-model-catalog' +import { directoryAccountBinding } from '../acp/launch/acp-account-binding' import { agentSessionSetupFor } from '../../shared/agent-session/lifecycle/agent-session-setup-commands' +import type { StructuredAgentSessionHistory } from '../native-chat/conversation-adoption/structured-agent-cli-conversations' -// The build hash parses as a semver prerelease, so this is the day before the first verified ACP build. -const CURSOR_ACP_MINIMUM_VERSION = '2026.10.21' -// cursor-agent reads ~/.cursor from the home directory and has no config-dir variable. -const CURSOR_ACCOUNT_HOME_VARIABLE = 'ORCA_CURSOR_CONFIG_DIR' -const CURSOR_CONFIG_FOLDER = '.cursor' - -export function cursorHostAccountHome(homePath: string): AgentSessionAccountHome { - return { variable: CURSOR_ACCOUNT_HOME_VARIABLE, path: join(homePath, CURSOR_CONFIG_FOLDER) } -} - -/** Chats pin the host's ~/.cursor. The variable is Orca's record key and is never given to the CLI. */ -export function isCursorHostAccountHome(home: AgentSessionAccountHome): boolean { - return ( - isLegacyAgentSessionAccountHome(home) && - home.variable === CURSOR_ACCOUNT_HOME_VARIABLE && - home.path.split(/[\\/]+/).findLast(Boolean) === CURSOR_CONFIG_FOLDER - ) -} - -// `cursor-agent models` is available before a chat exists. Its names are the ones the CLI shows. -function parseCursorAgentModels(stdout: string): AgentSessionModelOption[] { - return groupCursorCliModels(parseCursorModels(stdout)) -} - -// Ask Cursor for a model plus its effort and fast parameters, instead of one selector per variant. -const CURSOR_ACP_DIALECT: AcpDialect = { - clientCapabilities: { _meta: { parameterizedModelPicker: true } }, - fastConfigId: 'fast' -} - -// Terminal resumes use conversation ids. ACP sessions live in a separate store and cannot adopt them. +// `--resume ` reads the terminal store; it cannot open a chat's ACP session. const CURSOR_SESSION_HISTORY: StructuredAgentSessionHistory = { rowAgents: ['cursor'], rowSessionId: (link) => link.handle.nativeId, parseResumeArgs: () => null } -export const CURSOR_LAUNCH_SPEC = { +// The config folder holds Cursor's allowlist and sessions; its login lives elsewhere per platform. +function cursorConfigDirectory(homePath: string, env: NodeJS.ProcessEnv): string { + const xdg = env.XDG_CONFIG_HOME?.trim() + return xdg ? join(xdg, 'cursor') : join(homePath, '.cursor') +} + +export const CURSOR_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'cursor', command: 'cursor-agent', - args: ({ fullAccess }) => ['--disable-auto-update', ...(fullAccess ? ['--yolo'] : []), 'acp'], + // No bypass flag; full access approves permission requests individually. + args: () => ['--disable-auto-update', 'acp'], env: {}, - scrubEnvironment: () => [CURSOR_ACCOUNT_HOME_VARIABLE], dialect: CURSOR_ACP_DIALECT, loginCommand: agentSessionSetupFor('cursor')?.loginCommand ?? [], - authMethod: ({ advertised }) => - advertised.includes('cursor_login') ? 'cursor_login' : undefined, - account: { - pin: { accountHomeVariable: CURSOR_ACCOUNT_HOME_VARIABLE }, - resolve: async ({ launchEnv }) => - cursorHostAccountHome(launchEnv.HOME ?? launchEnv.USERPROFILE ?? homedir()), - environment: (home, env) => { - if (!isCursorHostAccountHome(home)) { - throw new Error("this chat uses the host's Cursor login") - } - const next = { ...env } - delete next[CURSOR_ACCOUNT_HOME_VARIABLE] - return next - } - }, + // No authMethod: `authenticate` while signed out opens a browser and waits. + account: directoryAccountBinding('CURSOR_CONFIG_DIR', cursorConfigDirectory), + // The install script's default prefix. installDirectories: ({ homePath }) => [join(homePath, '.local', 'bin')], - minimumVersions: [CURSOR_ACP_MINIMUM_VERSION], + // The build hash parses as a semver prerelease, so this is the day before the first verified ACP build. + minimumVersions: ['2026.10.21'], modelDiscovery: { - kind: 'command', - args: ['models'], - parse: parseCursorAgentModels, - // The CLI default is not the model a new ACP session actually starts on. + kind: 'initialize', + read: (_initialized, connection) => readCursorModelCatalog(connection), + // Each model's listed defaults are its own, not the model a new session starts on. listingNamesConfiguredModel: false }, transcriptImport: null, sessionHistory: CURSOR_SESSION_HISTORY, - // session/close does not exist, so a naming chat would stay in the user's Cursor history. + // No session/close or delete: a naming task's session would stay in the user's Cursor history. keepsEverySession: true -} satisfies AcpLaunchSpec +} diff --git a/src/main/native-chat/agent-session-wire/options/structured-agent-session-execution-options.ts b/src/main/native-chat/agent-session-wire/options/structured-agent-session-execution-options.ts index f59c6ece1ed..895d119dd47 100644 --- a/src/main/native-chat/agent-session-wire/options/structured-agent-session-execution-options.ts +++ b/src/main/native-chat/agent-session-wire/options/structured-agent-session-execution-options.ts @@ -25,11 +25,9 @@ export function captureStructuredExecutionOptions( ...selection } // A missing Fast pick has a definite baseline on hosts that offer it. - // Cursor's fast flag lives on the model selector and defaults per model, so a missing pick must not force it off. if ( candidate.fastMode === undefined && - ctx.agent !== 'cursor' && - ctx.agents.definition(ctx.agent)?.restingOptions.acceptsKey('fastMode') + ctx.agents.definition(ctx.agent)?.restingOptions.fastModeDefaultsOff ) { candidate.fastMode = 'false' } diff --git a/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts b/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts index d484a8acfa8..a1ed1ef07bc 100644 --- a/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts +++ b/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts @@ -109,7 +109,7 @@ export async function readStructuredAgentSessionOptionsAtRest( const listedModelIds = proof ? finishedListingIds(proof) : undefined const fastMode = saved.fastMode === undefined - ? rules?.acceptsKey('fastMode') + ? rules?.fastModeDefaultsOff ? false : null : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) diff --git a/src/main/native-chat/agent-session-wire/providers/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/providers/structured-agent-definition.ts index 450d9b769cd..fe7371d0004 100644 --- a/src/main/native-chat/agent-session-wire/providers/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/providers/structured-agent-definition.ts @@ -17,6 +17,8 @@ export type StructuredAgentDefinition = AgentSessionStoredAgent & { fallbackModels: () => AgentSessionModelOption[] | null /** Unpicked effort follows the model default, matching running-child reports. */ effortDefaultsToModel: boolean + /** An unpicked Fast is off; otherwise it follows the agent's per-model default. */ + fastModeDefaultsOff?: true } } diff --git a/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts b/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts index 514828410a5..9d800643626 100644 --- a/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts +++ b/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts @@ -182,7 +182,7 @@ export function structuredChatNamingDeps( const useChatAgent = !recipe.agentId && !recipe.agentArgs?.trim() const log = { scope: 'conversation-name', sessionId: record.sessionId } // These save every CLI run, so a naming run would sit in the user's own history. - if (useChatAgent && (record.provider === 'copilot' || record.provider === 'kimi')) { + if (useChatAgent && ['copilot', 'kimi', 'cursor'].includes(record.provider)) { return null } if (useChatAgent && (record.provider === 'grok' || record.provider === 'antigravity')) { diff --git a/src/shared/agent-launch/permissions/agent-chat-permission-support.ts b/src/shared/agent-launch/permissions/agent-chat-permission-support.ts index 531aa1fa964..c420fe11ab1 100644 --- a/src/shared/agent-launch/permissions/agent-chat-permission-support.ts +++ b/src/shared/agent-launch/permissions/agent-chat-permission-support.ts @@ -35,6 +35,7 @@ const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map() - for (const model of models) { - const modelId = cursorCliBaseId(model.id) - if (!modelId) { - continue - } - const current = groups.get(modelId) - const label = cursorCliBaseLabel(model.label, modelId) - if (!current || label.length < current.label.length) { - groups.set(modelId, { - label, - isDefault: current?.isDefault === true || model.isDefault === true - }) - } else if (model.isDefault) { - current.isDefault = true - } - } - return [...groups].map(([id, row]) => ({ - id, - label: row.label, - isDefault: row.isDefault, - efforts: [] - })) -} - -function cursorCliBaseId(id: string): string | null { - if (id === 'auto') { - return 'default' - } - let rest = id.startsWith('cursor-') ? id.slice('cursor-'.length) : id - if (rest.endsWith('-fast')) { - rest = rest.slice(0, -'-fast'.length) - } - for (const level of CLI_EFFORTS) { - const suffix = `-${level}` - if (rest.endsWith(suffix)) { - rest = rest.slice(0, -suffix.length) - break - } - } - return rest.length > 0 ? rest : null -} - -function cursorCliBaseLabel(label: string, modelId: string): string { - if (modelId === 'default') { - return 'Auto' - } - let text = label - let previous = '' - while (text !== previous) { - previous = text - text = text.replace(/ +Fast$/i, '').replace(/ +(Low|Medium|High|Extra High|Max)$/i, '') - } - const stripped = text.replace(/ {2,}/g, ' ').trim() - return stripped.length > 0 ? stripped : labelFromModelId(modelId) -}