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.
This commit is contained in:
Kelvin Amoaba
2026-11-13 22:56:35 +00:00
parent 0be4e4a0f6
commit 01cef552f9
19 changed files with 550 additions and 289 deletions
+2 -2
View File
@@ -160,6 +160,8 @@ export type AcpDialect = {
clampsUnlistedEffort?: true
/** Opt-in because advertising forms changes agent behaviour; requests use the generic handlers. */
clientCapabilities?: Pick<ClientCapabilities, 'elicitation' | '_meta'>
/** 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
}
+160
View File
@@ -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/<path>` 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
}
@@ -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<unknown>
}): Promise<AgentSessionModelOption[]> {
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')
}
})
}
+6 -2
View File
@@ -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) {
@@ -10,7 +10,7 @@ import type { SessionConfigOption, SessionModelState } from '../generated/acp-pr
type SelectOption = Extract<SessionConfigOption, { type: 'select' }>
export function flatAcpModelMenu(input: {
dialect: Pick<AcpDialect, 'modelEfforts' | 'modelChoiceLabel'>
dialect: Pick<AcpDialect, 'modelEfforts'>
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) => {
@@ -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
}
+10 -6
View File
@@ -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<Record<string, string | undefined>> {
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) {
@@ -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)
+266 -129
View File
@@ -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<unknown>) {
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' } })
})
@@ -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
}
@@ -22,6 +22,7 @@ export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = {
restingOptions: {
acceptsKey: isClaudeStructuredOptionKey,
fallbackModels: claudeFallbackModelOptions,
effortDefaultsToModel: true
effortDefaultsToModel: true,
fastModeDefaultsOff: true
}
}
+24 -68
View File
@@ -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 <id>` 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
}
@@ -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'
}
@@ -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)
@@ -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
}
}
@@ -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')) {
@@ -35,6 +35,7 @@ const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map<string, AgentChatPermission
['opencode2', ACP_CHAT_PERMISSION_MODES],
['omp', ACP_CHAT_PERMISSION_MODES],
['copilot', ACP_CHAT_PERMISSION_MODES],
['cursor', ACP_CHAT_PERMISSION_MODES],
['kimi', KIMI_CHAT_PERMISSION_MODES],
['antigravity', ANTIGRAVITY_CHAT_PERMISSION_MODES]
])
@@ -44,7 +44,11 @@ export const AGENT_SESSION_SETUP_COMMANDS = [
loginCommand: ['kimi', 'login'],
installCommand: 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash'
},
{ agent: 'cursor', loginCommand: ['cursor-agent', 'login'] },
{
agent: 'cursor',
loginCommand: ['cursor-agent', 'login'],
installCommand: 'curl https://cursor.com/install -fsS | bash'
},
// An `agy` login does not sign its chats in: they keep their own sign-in.
{ agent: 'antigravity', loginCommand: [] }
] as const
-66
View File
@@ -1,66 +0,0 @@
import type { AgentSessionModelOption } from './agent-session/agent-session-wire'
import { labelFromModelId } from './model-id-label'
// Cursor's CLI lists one row per effort and fast variant. The parameterized ACP picker uses the base id.
const CLI_EFFORTS = ['max', 'xhigh', 'high', 'medium', 'low'] as const
/** Collapse `cursor-agent models` rows onto one model per family, without effort or fast in the name. */
export function groupCursorCliModels(
models: readonly { id: string; label: string; isDefault?: boolean }[]
): AgentSessionModelOption[] {
const groups = new Map<string, { label: string; isDefault: boolean }>()
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)
}