mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-3
@@ -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'
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user